Ansible basics · Lesson 4 of 12 · about 25 minutes

Variables and facts

A playbook that does the same thing everywhere is only half useful. This lesson makes it fit each server: the facts Ansible discovers (memory, address, operating system), variables from the inventory and the playbook, values captured from commands, values worked out on the fly, one server reading another's facts, and which setting wins when several say different things. Then a home page built from all of it, and a mismatch Lesson 5 fixes.

By the end you'll know

  • What facts are, how to find one, and how to use it in a task
  • Where variables can be set, and which one wins
  • How to capture a command's output with register, and stop it counting as a change
  • How to work out a new value with set_fact, and a condition inside {{ }}
  • How one server can use another server's facts, through hostvars
  • What happens with a variable nobody set, and how default helps
  • What gathering facts costs, and when to skip it

Before you start

  • You need: Lessons 1 to 3 done, with the three servers running and site.yml applied.
  • Get the new files: in ansible-practice, git pull. You'll have facts.yml, a playbook for exploring, and a new task at the end of site.yml.
  • Where commands run: your own computer's terminal, in ansible-practice.

Making one playbook fit many servers

Lesson 3's playbook did the same thing on every server it touched. Real servers differ: different addresses, different amounts of memory, different jobs, the odd one with a different port. You don't want a playbook per server; you want one playbook that fits each one.

Two things make that possible. Facts are what Ansible finds out about each server by looking. Variables are what you tell it. Think of a mail merge. One letter, written once, with gaps: "Dear {name}, your order {number} ships on {date}." The merge fills the gaps from a list, so every letter is right for its reader. A playbook is the letter, {{ }} marks the gaps, and facts and variables are the list.

Facts

Every play starts with Gathering Facts: the setup module from Lesson 1, run on each server. It finds out a lot:

BASH
ansible web1 -m setup | wc -l
OUTPUT
     909

909 lines about one small server: hardware, network, disks, operating system, environment. A filter picks one out:

BASH
ansible web1 -m setup -a "filter=ansible_default_ipv4"
OUTPUT
web1 | SUCCESS => {
    "ansible_facts": {
        "ansible_default_ipv4": {
            "address": "192.168.34.133",
            "alias": "eth0",
            "broadcast": "",
            "gateway": "192.168.34.2",
            "interface": "eth0",
            "macaddress": "00:0c:29:78:03:b0",
            "mtu": 1500,
            "netmask": "255.255.255.0",
            "network": "192.168.34.0",
            "prefix": "24",
            "type": "ether"
        },
        "discovered_interpreter_python": "/usr/bin/python3.12"
    },
    "changed": false
}

Everything about web1's main network interface: its address, its gateway, its network, the Networking track's first lessons in one block. In a playbook, facts live in ansible_facts, without the ansible_ on the front, and you reach into them with square brackets: ansible_facts['default_ipv4']['address'] is 192.168.34.133.

The exploring playbook

facts.yml is a playbook for looking around. Its first task puts several facts in one message:

YAML
- name: Variables and facts
  hosts: bookshop
  vars:
    greeting: hello from the play
  tasks:
    - name: A few of the facts Ansible gathered
      ansible.builtin.debug:
        msg: >-
          {{ inventory_hostname }} runs {{ ansible_facts['distribution'] }}
          {{ ansible_facts['distribution_version'] }}, with
          {{ ansible_facts['memtotal_mb'] }} MB of memory and
          {{ ansible_facts['processor_vcpus'] }} CPU, at
          {{ ansible_facts['default_ipv4']['address'] }}

>- is YAML for "one long line, written over several": the line breaks become spaces. Run the whole playbook:

BASH
ansible-playbook facts.yml
OUTPUT
TASK [A few of the facts Ansible gathered] *************************************
ok: [web1] => {
    "msg": "web1 runs Ubuntu 24.04, with 953 MB of memory and 1 CPU, at 192.168.34.133"
}
ok: [web2] => {
    "msg": "web2 runs Ubuntu 24.04, with 953 MB of memory and 1 CPU, at 192.168.34.134"
}
ok: [db1] => {
    "msg": "db1 runs Ubuntu 24.04, with 953 MB of memory and 1 CPU, at 192.168.34.135"
}

One task, three servers, three right answers, each from its own facts. Nobody typed an address. The rest of the run is in the sections below.

Variables, and where they come from

The next task mixes the inventory's variables from Lesson 2 with one set in the playbook itself:

YAML
    - name: Variables from the inventory, and one from the play
      ansible.builtin.debug:
        msg: >-
          site_name={{ site_name | default('(not set)') }}
          http_port={{ http_port | default('(not set)') }}
          admin_email={{ admin_email }}
          greeting={{ greeting }}
OUTPUT
TASK [Variables from the inventory, and one from the play] *********************
ok: [web1] => {
    "msg": "site_name=bookshop http_port=80 admin_email=ops@bookshop.example greeting=hello from the play"
}
ok: [web2] => {
    "msg": "site_name=bookshop http_port=8080 admin_email=ops@bookshop.example greeting=hello from the play"
}
ok: [db1] => {
    "msg": "site_name=(not set) http_port=(not set) admin_email=ops@bookshop.example greeting=hello from the play"
}
  • site_name and http_port come from group_vars/web.yml, with web2's own 8080 from the inventory line, as in Lesson 2.
  • admin_email comes from group_vars/all.yml, so every server has it.
  • greeting comes from the play's own vars: section, so every server in the play has it.
  • db1 isn't a web server, so it has no site_name or http_port. | default('(not set)') is a filter: it takes the value on its left and returns something else. default returns the value if there is one, and its fallback if not. Without it, this task would have failed on db1. More on that below.

Which one wins when several places set the same variable? Ansible has a long list of rules, but the order that matters day to day is short, weakest first:

  1. group_vars/all.yml
  2. group_vars/ for a named group
  3. host_vars/, or a setting on the server's inventory line
  4. the play's vars:
  5. -e on the command line, which beats everything

Test the strongest. -e ("extra vars") sets variables for one run:

BASH
ansible-playbook facts.yml --limit web2 -e "greeting=from_the_command_line http_port=9999"
OUTPUT
TASK [Variables from the inventory, and one from the play] *********************
ok: [web2] => {
    "msg": "site_name=bookshop http_port=9999 admin_email=ops@bookshop.example greeting=from_the_command_line"
}

(Just that task shown.) -e beat the play's greeting, and it beat web2's own 8080. That's what it's for: a one-off override, like testing a different port, without editing any file. It's also why it should stay a one-off. A setting that only ever lives in someone's command line isn't written down anywhere.

Capturing output: register

Some information only comes from running a command. register saves a task's whole result in a variable:

YAML
    - name: Ask nginx for its version
      ansible.builtin.command: nginx -v
      register: nginx_version
      changed_when: false
      when: "'web' in group_names"

    - name: Show everything that was captured
      ansible.builtin.debug:
        var: nginx_version
      when: "'web' in group_names"
OUTPUT
TASK [Ask nginx for its version] ***********************************************
skipping: [db1]
ok: [web1]
ok: [web2]

TASK [Show everything that was captured] ***************************************
ok: [web1] => {
    "nginx_version": {
        "changed": false,
        "cmd": [
            "nginx",
            "-v"
        ],
        "delta": "0:00:00.001683",
        "end": "2026-11-01 05:47:35.917893",
        "failed": false,
        "msg": "",
        "rc": 0,
        "start": "2026-11-01 05:47:35.916210",
        "stderr": "nginx version: nginx/1.24.0 (Ubuntu)",
        "stderr_lines": [
            "nginx version: nginx/1.24.0 (Ubuntu)"
        ],
        "stdout": "",
        "stdout_lines": []
    }
}
...
skipping: [db1]

Everything about the command: what ran, how long it took, its exit code rc, and what it printed. And a surprise: stdout is empty. The version is in stderr. nginx -v prints to the error stream, stream 2, not to standard output. Anyone writing nginx_version.stdout would get nothing, and wonder why. Look at a registered result once, like this, before using it.

Two more details in that task:

  • changed_when: false tells Ansible this command never changes anything. Without it, command always reports changed, as Lesson 1 showed, and the recap would never reach changed=0. Use it on every command that only looks.
  • when: "'web' in group_names" runs the task only on web servers, so db1 is skipping. Lesson 6 covers conditions properly.

Working out a value: set_fact

set_fact creates a variable during the run, from anything you can write in {{ }}, including an if:

YAML
    - name: Work out a value from the facts
      ansible.builtin.set_fact:
        memory_class: "{{ 'small' if ansible_facts['memtotal_mb'] < 2048 else 'large' }}"

    - name: Use it
      ansible.builtin.debug:
        msg: "{{ inventory_hostname }} is a {{ memory_class }} server"
OUTPUT
TASK [Use it] ******************************************************************
ok: [web1] => {
    "msg": "web1 is a small server"
}
ok: [web2] => {
    "msg": "web2 is a small server"
}
ok: [db1] => {
    "msg": "db1 is a small server"
}

All three are 953 MB, so all three are small. The point is the shape: a fact goes in, a decision comes out, and later tasks use the decision. A real playbook might size a database's memory settings this way, from how much memory the server actually has.

One server's facts, from another

Web servers need to know where the database is. Typing its address into a file is how configs go stale the day the address changes. Instead, use db1's own facts, through hostvars: every server's variables and facts, by name.

YAML
    - name: Find the database, from db1's own facts
      ansible.builtin.debug:
        msg: >-
          The database is at
          {{ hostvars['db1']['ansible_facts']['default_ipv4']['address'] }}:{{ hostvars['db1']['db_port'] }}
      when: "'web' in group_names"
OUTPUT
TASK [Find the database, from db1's own facts] *********************************
ok: [web1] => {
    "msg": "The database is at 192.168.34.135:5432"
}
skipping: [db1]
ok: [web2] => {
    "msg": "The database is at 192.168.34.135:5432"
}

db1's address, from its gathered facts, and its port, from host_vars/db1.yml, both read while working on the web servers. This only works if db1's facts were gathered in the same run, which they were, because this play includes db1. Run it with --limit web1, and db1 is never contacted, so hostvars['db1'] has no facts, and the task fails. That's a classic surprise: a playbook that works in full and fails when limited.

The recap for the whole run:

OUTPUT
PLAY RECAP *********************************************************************
db1                        : ok=5    changed=0    unreachable=0    failed=0    skipped=3    rescued=0    ignored=0   
web1                       : ok=8    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
web2                       : ok=8    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   

changed=0, even with a command task in it, thanks to changed_when: false. And skipped=3 for db1: the three web-only tasks.

A variable nobody set

A tiny playbook that uses http_port on db1, which doesn't have one, then the same with a default:

YAML
- name: Undefined
  hosts: db1
  gather_facts: false
  tasks:
    - name: Use a variable db1 does not have
      ansible.builtin.debug:
        msg: "Listening on {{ http_port }}"
    - name: With a default
      ansible.builtin.debug:
        msg: "Listening on {{ http_port | default(80) }}"
OUTPUT
TASK [Use a variable db1 does not have] ****************************************
fatal: [db1]: FAILED! => {"msg": "The task includes an option with an undefined variable.. 'http_port' is undefined\n\nThe error appears to be in '/Users/you/Apps/ansible-practice/undefined.yml': line 5, column 7, but may\nbe elsewhere in the file depending on the exact syntax problem.\n\nThe offending line appears to be:\n\n  tasks:\n    - name: Use a variable db1 does not have\n      ^ here\n"}

PLAY RECAP *********************************************************************
db1                        : ok=0    changed=0    unreachable=0    failed=1    skipped=0    rescued=0    ignored=0   

'http_port' is undefined, and the run stops there, so the second task never ran. That's the right behavior: a config file with an empty port is worse than an error. With the first task taken out:

OUTPUT
TASK [With a default] **********************************************************
ok: [db1] => {
    "msg": "Listening on 80"
}

Use default only when there really is a sensible default. For something that must be set, like a password or a database address, let it fail loudly. A quiet default there is a bug waiting.

Facts cost time

Gathering facts is an extra round trip to every server. A one-task playbook, with and without:

OUTPUT
$ time ansible-playbook timing.yml | tail -5
PLAY RECAP *********************************************************************
db1                        : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
...
real	0m1.833s

$ time ansible-playbook timing.yml | tail -5
PLAY RECAP *********************************************************************
db1                        : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
...
real	0m0.884s

The second has gather_facts: false in its play: ok=1 instead of 2, and half the time. On three servers that's a second; on hundreds, every play, it adds up. Turn it off for plays that use no facts, like the undefined-variable example above. Leave it on when in doubt: a missing fact is an error mid-run.

Variables in site.yml: a home page

Now put it to work. site.yml's web play has a new task at the end, a home page built from variables and facts:

YAML
    - name: Put up the bookshop's home page
      ansible.builtin.copy:
        dest: /var/www/html/index.html
        content: |
          <h1>{{ site_name }}</h1>
          <p>Served by {{ inventory_hostname }} ({{ ansible_facts['default_ipv4']['address'] }}), on port {{ http_port }}.</p>
          <p>Questions? {{ admin_email }}</p>
        owner: root
        group: root
        mode: "0644"

The dry run shows each server's own page:

BASH
ansible-playbook site.yml --check --diff --limit web
OUTPUT
TASK [Put up the bookshop's home page] *****************************************
--- before
+++ after: /var/www/html/index.html
@@ -0,0 +1,3 @@
+<h1>bookshop</h1>
+<p>Served by web1 (192.168.34.133), on port 80.</p>
+<p>Questions? ops@bookshop.example</p>

changed: [web1]
--- before
+++ after: /var/www/html/index.html
@@ -0,0 +1,3 @@
+<h1>bookshop</h1>
+<p>Served by web2 (192.168.34.134), on port 8080.</p>
+<p>Questions? ops@bookshop.example</p>

changed: [web2]

(Here, --limit web is safe: this task only uses the web servers' own facts.) Run it, and ask each web server for its page:

BASH
ansible-playbook site.yml
ansible web -m command -a "curl -s http://localhost"
OUTPUT
PLAY RECAP *********************************************************************
db1                        : ok=5    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
web1                       : ok=9    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
web2                       : ok=9    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   

web1 | CHANGED | rc=0 >>
<h1>bookshop</h1>
<p>Served by web1 (192.168.34.133), on port 80.</p>
<p>Questions? ops@bookshop.example</p>
web2 | CHANGED | rc=0 >>
<h1>bookshop</h1>
<p>Served by web2 (192.168.34.134), on port 8080.</p>
<p>Questions? ops@bookshop.example</p>

One change per web server, nothing else touched, and each page names its own server and address. But look at web2's: "on port 8080", and it answered on port 80. Ask it on 8080:

BASH
ansible web2 -m command -a "curl -s -o /dev/null -w %{http_code} http://localhost:8080"
OUTPUT
web2 | FAILED | rc=7 >>
000non-zero return code

Nothing is listening there. Exit code 7 is curl's "couldn't connect", and 000 means no answer at all. The variable says 8080; nginx's own config still says 80, because nothing ever told nginx about the variable. A variable only matters where a file or a task uses it. Making nginx's config come from the variables, and restarting nginx only when it changes, is exactly what Lesson 5 does.

In real systems

  • Facts replace a lot of hard-coded config. Addresses, memory sizes and CPU counts change when servers are rebuilt or resized. Configs built from facts follow along; configs with numbers typed in go stale.
  • hostvars is how servers find each other. Web servers find the database, load balancers find the web servers, monitoring finds everything, all from one inventory. Kubernetes solves the same problem with Services and DNS, which is why CivicGrid's API reaches its database by name, not address.
  • Gathered facts can be cached between runs, in a file or Redis (Lesson 14 of the SQL track), so big fleets don't gather on every run. It's a setting in ansible.cfg.

Spot the problem

Work out what's wrong before you read the explanation.

The emergency port change. During an incident, someone moves web2 to port 9090 with ansible-playbook site.yml --limit web2 -e http_port=9090. It works. A week later, a teammate runs the playbook normally, to add a package, and web2 goes back to its old port, breaking whatever was pointed at 9090. Whose fault is it, and what should have happened? Neither person did anything wrong by the rules, which is the problem. -e is for one run only; it isn't written down anywhere, so the next normal run used host_vars and the inventory again, as designed. A change meant to last belongs in the files: http_port=9090 on web2's inventory line, committed and reviewed. Keep -e for experiments and one-offs, and treat any -e used in production as "remember to write this down".

Keep this

ToRun or write
Find a factansible web1 -m setup -a "filter=ansible_default_ipv4"
Use a fact{{ ansible_facts['default_ipv4']['address'] }}
Variables for one playvars:, then name: value
Override for one runansible-playbook site.yml -e "http_port=9999"
A fallback value{{ http_port | default(80) }}
Save a task's resultregister: nginx_version, then nginx_version.stderr
A command that never changes anythingchanged_when: false
Work out a new variableansible.builtin.set_fact: with {{ 'a' if x else 'b' }}
Another server's facts{{ hostvars['db1']['ansible_facts']['default_ipv4']['address'] }}
Print a variable while runningansible.builtin.debug: with var: name or msg: "..."
Skip gathering factsgather_facts: false, in the play

Where you are now

You can make one playbook fit many servers: facts for what they are, variables for what you want, register and set_fact for what you learn along the way, and hostvars for what they need to know about each other. And web2 says 8080 while nginx listens on 80. Lesson 5 fixes that with a template for nginx's config, and a handler that reloads nginx only when the config changes.

Check yourself

  1. web2 has http_port=8080 in the inventory. You run with -e http_port=9999. Which does it use?

  2. nginx_version.stdout was empty after nginx -v. Where was the version?

  3. Why does the nginx -v task have changed_when: false?

  4. A task reads hostvars['db1']['ansible_facts']. It fails with --limit web1. Why?

  5. When should you use default() for a variable?

  6. web2's page says port 8080, but nothing answers on 8080. Why?

Key terms

  • Facts: what Ansible discovers about each server, in ansible_facts.
  • Variable: a named value you set: in the inventory, group_vars, host_vars, a play's vars:, or -e.
  • Precedence: which setting wins when several set the same variable. -e beats all.
  • Extra vars: variables set for one run with -e.
  • Filter: a function used with | inside {{ }}, like default.
  • register: saving a task's result in a variable.
  • set_fact: creating a variable during a run.
  • hostvars: every server's variables and facts, by name.

Go deeper