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
defaulthelps - 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.ymlapplied. - Get the new files: in
ansible-practice,git pull. You'll havefacts.yml, a playbook for exploring, and a new task at the end ofsite.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:
ansible web1 -m setup | wc -l
909
909 lines about one small server: hardware, network, disks, operating system, environment. A filter picks one out:
ansible web1 -m setup -a "filter=ansible_default_ipv4"
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:
- 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:
ansible-playbook facts.yml
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:
- 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 }}
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_nameandhttp_portcome fromgroup_vars/web.yml, withweb2's own8080from the inventory line, as in Lesson 2.admin_emailcomes fromgroup_vars/all.yml, so every server has it.greetingcomes from the play's ownvars:section, so every server in the play has it.db1isn't a web server, so it has nosite_nameorhttp_port.| default('(not set)')is a filter: it takes the value on its left and returns something else.defaultreturns the value if there is one, and its fallback if not. Without it, this task would have failed ondb1. 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:
group_vars/all.ymlgroup_vars/for a named grouphost_vars/, or a setting on the server's inventory line- the play's
vars: -eon the command line, which beats everything
Test the strongest. -e ("extra vars") sets variables for one run:
ansible-playbook facts.yml --limit web2 -e "greeting=from_the_command_line http_port=9999"
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:
- 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"
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: falsetells Ansible this command never changes anything. Without it,commandalways reportschanged, as Lesson 1 showed, and the recap would never reachchanged=0. Use it on every command that only looks.when: "'web' in group_names"runs the task only on web servers, sodb1isskipping. 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:
- 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"
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.
- 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"
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:
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:
- 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) }}"
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:
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:
$ 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:
- 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:
ansible-playbook site.yml --check --diff --limit web
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:
ansible-playbook site.yml
ansible web -m command -a "curl -s http://localhost"
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:
ansible web2 -m command -a "curl -s -o /dev/null -w %{http_code} http://localhost:8080"
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.
hostvarsis 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
| To | Run or write |
|---|---|
| Find a fact | ansible web1 -m setup -a "filter=ansible_default_ipv4" |
| Use a fact | {{ ansible_facts['default_ipv4']['address'] }} |
| Variables for one play | vars:, then name: value |
| Override for one run | ansible-playbook site.yml -e "http_port=9999" |
| A fallback value | {{ http_port | default(80) }} |
| Save a task's result | register: nginx_version, then nginx_version.stderr |
| A command that never changes anything | changed_when: false |
| Work out a new variable | ansible.builtin.set_fact: with {{ 'a' if x else 'b' }} |
| Another server's facts | {{ hostvars['db1']['ansible_facts']['default_ipv4']['address'] }} |
| Print a variable while running | ansible.builtin.debug: with var: name or msg: "..." |
| Skip gathering facts | gather_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
web2 has http_port=8080 in the inventory. You run with -e http_port=9999. Which does it use?
nginx_version.stdout was empty after nginx -v. Where was the version?
Why does the nginx -v task have changed_when: false?
A task reads hostvars['db1']['ansible_facts']. It fails with --limit web1. Why?
When should you use default() for a variable?
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'svars:, or-e. - Precedence: which setting wins when several set the same variable.
-ebeats all. - Extra vars: variables set for one run with
-e. - Filter: a function used with
|inside{{ }}, likedefault. - 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
- The Ansible documentation: Using variables (opens in a new tab), including the full precedence list, and Discovering variables: facts and magic variables (opens in a new tab).
- Try: add
kernelanduptime_secondsto the facts message infacts.yml. (Find their names withansible web1 -m setup | grep -i kernel.) - Try: run
ansible-playbook facts.yml --limit web1and read the last task's error. Then adddb1to the limit and run it again.