Module 02 — Variables, Facts & Precedence
Updated 20 August 2026
This is where mid-level and senior candidates get separated. Anyone can write a variable; far fewer can explain which of six definitions actually wins, and why.
🧠 concept → 🧪 exercise → ✅ expected result (hidden) → 🎯 interview questions (answers hidden)
Prerequisite: Module 01. You have already met ansible_distribution (Exercise C2.1) and inventory_hostname (Exercise D4.1) — this module explains what they actually are.
Part A · Variables from the ground up
A1 · What a variable is, and how you reference it
That is the whole point of a variable, and it is why one playbook can serve both staging and production. The {{ }} braces mean "go and look inside this jar, and put whatever you find right here". Keep the jar in mind — it comes back in D5, when the jar labelled sugar turns out to be full of salt.
Ansible variables are referenced with Jinja2 templating syntax — double curly braces:
vars:
http_port: 8080
app_name: checkout
tasks:
- name: Show the config
ansible.builtin.debug:
msg: "{{ app_name }} listens on port {{ http_port }}"The naming rules — stricter than people expect
| Rule | Detail |
|---|---|
| Letters, digits, underscores only | app_port ✅ · app-port ❌ · app.port ❌ · app port ❌ |
| Must not start with a digit | 2nd_server ❌ · server_2 ✅ |
| Must not collide with a Python keyword | async, lambda, import are all invalid |
| Must not collide with a playbook keyword | environment, name, hosts — technically allowed but they shadow real keywords and cause baffling bugs |
The quoting rule you cannot skip
msg: {{ http_port }} # ❌ YAML sees a line starting with { and expects a dictionary
msg: "{{ http_port }}" # ✅ quote it
msg: "Port is {{ http_port }}" # ✅ fine either way, but quote for consistency
when: http_port == 8080 # ✅ NO braces here - when: is already a Jinja2 expression
when: "{{ http_port }} == 8080" # ⚠️ works but is wrong style, and ansible-lint flags it🧪 Exercise A1.1 — Break the quoting rule on purpose
---
- name: Quoting demo
hosts: localhost
gather_facts: false
vars:
http_port: 8080
tasks:
- name: This one fails
ansible.builtin.debug:
msg: {{ http_port }}✅ Expected result — click to reveal
ERROR! We were unable to read either as JSON nor YAML, these are the errors we got from each:
JSON: Expecting value: line 1 column 1 (char 0)
Syntax Error while loading YAML.
found unacceptable key (unhashable type: AnsibleMapping)
The offending line appears to be:
ansible.builtin.debug:
msg: {{ http_port }}
^ here
We could be wrong, but this one looks like it might be an issue with
missing quotes. Always quote template expression brackets when they
start a value. For instance:
with_items:
- {{ foo }}
Should be written as:
with_items:
- "{{ foo }}"Ansible tells you the fix directly — this is one of the few error messages that names the exact problem. Worth triggering once so you recognise "found unacceptable key (unhashable type: AnsibleMapping)" instantly. It means: YAML saw { at the start of a value and tried to parse a dictionary.
🎯 Interview questions — Variable basics
Q. How are variables referenced in Ansible, and what templating engine is used?
Jinja2, with {{ variable_name }}. Ansible templates strings at run time, so variables can be used in task arguments, in when: conditions, in templates, and in file paths.
The two syntax rules worth stating: quote the value if it starts with {{, and do not use braces inside when:, which is already evaluated as a Jinja2 expression.
Q. What are the rules for a valid variable name?
Letters, digits and underscores only; cannot begin with a digit; cannot be a Python keyword.
The practical trap is hyphens — app-port is invalid because Jinja2 parses it as subtraction, and it can fail silently rather than loudly. Also avoid names that collide with playbook keywords like environment or name.
A2 · Variable types, and how to reach inside them
The jar is a string or a number, the shopping list is a list, and the address book is a dictionary. How you reach inside follows straight from the shape: packages[0] is "the first thing on the list", and admin_user.name is "the entry filed under name". Almost every baffling template error later in this track is really someone treating an address book like a shopping list.
# String
app_name: checkout
# Number - integer or float
max_connections: 100
timeout_seconds: 2.5
# Boolean
debug_mode: true # also accepted: yes, on, y, True
# List
packages:
- nginx
- git
- htop
# Dictionary
admin_user:
name: zaeem
uid: 1001
shell: /bin/bash
# List of dictionaries - extremely common in real playbooks
app_users:
- name: deploy
uid: 2001
- name: monitor
uid: 2002Accessing them
"{{ packages[0] }}" # nginx - list by index
"{{ packages | length }}" # 3 - a filter
"{{ admin_user.name }}" # zaeem - dot notation
"{{ admin_user['name'] }}" # zaeem - bracket notation (identical result)
"{{ app_users[1].name }}" # monitor - combining both- Keys with special characters or spaces — myvar.some-key fails, myvar['some-key'] works
- Keys that collide with Python dictionary methods — myvar.keys, myvar.items, myvar.count, myvar.update, myvar.pop. These silently return the Python method object instead of your value, which produces genuinely bewildering output rather than an error
The safe habit: use bracket notation for anything coming from external data — an API response, a JSON file, a cloud tag. Use dot notation only for variables you defined yourself and can see.
Booleans — the YAML gotcha that reaches into Ansible
enabled: yes # -> True (boolean)
enabled: "yes" # -> "yes" (string)
version: 1.10 # -> 1.1 (float! the trailing zero is lost)
version: "1.10" # -> "1.10" (string, correct)
country: NO # -> False (boolean!) - the infamous Norway problem
country: "NO" # -> "NO" (string, correct)🧪 Exercise A2.1 — Make the dot-notation trap happen
---
- name: Dot notation trap
hosts: localhost
gather_facts: false
vars:
config:
name: myapp
keys: ["alpha", "beta"]
tasks:
- name: Dot notation on a key called 'keys'
ansible.builtin.debug:
msg: "{{ config.keys }}"
- name: Bracket notation on the same key
ansible.builtin.debug:
msg: "{{ config['keys'] }}"✅ Expected result — click to reveal
TASK [Dot notation on a key called 'keys'] *************************
ok: [localhost] => {
"msg": "<built-in method keys of dict object at 0x7f3a2c1d4a40>"
}
TASK [Bracket notation on the same key] ****************************
ok: [localhost] => {
"msg": ["alpha", "beta"]
}No error. No warning. config.keys returned Python's built-in dict.keys method object, because dot notation resolves attributes before dictionary keys, and every Python dict has a .keys attribute.
Now imagine that value being written into a config file by a template task. You would deploy the literal string <built-in method keys of dict object at 0x7f3a2c1d4a40> into production and spend a long time working out where it came from.
The reserved names to watch: keys, items, values, count, index, update, pop, get, copy, clear. Use bracket notation for any data you did not personally write.
🎯 Interview questions — Types & access
Q. Dot notation vs bracket notation — which do you prefer and why?
They behave identically for simple keys, but bracket notation is safer in two situations.
First, keys containing special characters or spaces — myvar['some-key'] works where myvar.some-key does not.
Second, and more dangerous, keys that collide with Python dict methods: keys, items, count, update, pop. Dot notation returns the method object rather than your value, silently, with no error.
So: dot notation for variables I wrote myself, bracket notation for anything from an external source such as an API response or cloud tags.
Q. What is the "Norway problem"?
A YAML parsing bug where the unquoted country code NO is interpreted as the boolean false. The same applies to ON, OFF, Y, N, YES.
The fix is to quote any string that could be mistaken for a boolean or a number. The related cases are version strings like 1.10, which becomes the float 1.1, and file modes like 0644.
A3 · Where variables can be defined — the map
Ansible has exactly this problem and solves it exactly that way. A variable can be set in twenty-two different places, and the list below is the settled answer for which one applies. The printed invitation is a role default at the bottom; the groom's text message is -e on the command line at the top.
Before the precedence rules make sense, you need to know the terrain. There are roughly a dozen distinct places a variable can come from.
Diagram source
flowchart LR
subgraph INV["INVENTORY"]
I1["inventory file<br>host and group vars"]
I2["group_vars/ directory"]
I3["host_vars/ directory"]
end
subgraph PB["PLAYBOOK"]
P1["play vars:"]
P2["vars_files:"]
P3["vars_prompt:"]
P4["block vars"]
P5["task vars"]
end
subgraph RL["ROLES"]
R1["defaults/main.yml<br>LOWEST priority"]
R2["vars/main.yml<br>high priority"]
R3["role params"]
end
subgraph RT["RUNTIME"]
T1["gathered facts"]
T2["set_fact"]
T3["register"]
T4["include_vars"]
end
subgraph CLI["COMMAND LINE"]
C1["--extra-vars<br>ALWAYS WINS"]
end
INV --> M{{"Precedence<br>resolution"}}
PB --> M
RL --> M
RT --> M
CLI --> M
M --> F["One final value<br>per host, per variable"]
style C1 fill:#FEE2E2,stroke:#DC2626,stroke-width:2px
style R1 fill:#F1F5F9,stroke:#64748B
style F fill:#D1FAE5,stroke:#059669,stroke-width:2pxRole defaults always lose. They are the lowest priority of anything — that is the entire point of defaults/main.yml, it exists to be overridden.
Extra vars always win. -e beats everything, unconditionally, with no exceptions.
If you remember nothing else from this module, remember those two sentences. Most precedence questions are really asking whether you know the ends of the ladder.
Part B · Facts
B1 · What facts are
Facts are that check-up. Ansible connects to each server and measures it — which OS, how much memory, which IP addresses, which disks — before a single one of your tasks runs, which is why ansible_facts is trustworthy in a way that a value somebody typed into a file is not. And like a real check-up it costs time: if you only came in for a repeat prescription, the full examination was wasted. That is what gather_facts: false is for.
Facts are variables Ansible discovers about a host, automatically, by inspecting it. You do not define them — the setup module gathers them at the start of every play where gather_facts: true, which is the default.
You already triggered this in Module 01: the TASK [Gathering Facts] line that appeared in your playbook output without you writing it, and the ansible_distribution you filtered in Exercise C2.1.
What you get
| Fact | Example value |
|---|---|
| ansible_distribution | Ubuntu |
| ansible_distribution_major_version | 22 |
| ansible_os_family | Debian — groups Ubuntu/Debian, or RedHat/CentOS/Rocky. Use this, not ansible_distribution, for cross-distro conditionals |
| ansible_default_ipv4.address | 10.0.1.15 |
| ansible_hostname / ansible_fqdn | web01 / web01.example.com |
| ansible_processor_vcpus | 4 |
| ansible_memtotal_mb | 7936 |
| ansible_mounts | a list of dicts — device, mount point, size, free space |
| ansible_service_mgr | systemd |
| ansible_python_version | 3.11.2 — the target's Python, from Module 01 Part B6 |
The ansible_facts dictionary vs the flat ansible_* names
Both of these work and refer to the same data:
"{{ ansible_facts['distribution'] }}" # the modern, recommended form
"{{ ansible_distribution }}" # the legacy "injected" formThe flat form exists because Ansible injects each fact as a top-level variable prefixed with ansible_. That injection is controlled by inject_facts_as_vars in ansible.cfg, which defaults to true but can be turned off — at which point every playbook using the flat form breaks.
🧪 Exercise B1.1 — See the full scale of what is gathered
ansible web01 -m ansible.builtin.setup | wc -l
ansible web01 -m ansible.builtin.setup -a 'filter=ansible_distribution*'
ansible web01 -m ansible.builtin.setup -a 'filter=ansible_mem*'
ansible web01 -m ansible.builtin.setup -a 'filter=ansible_default_ipv4'✅ Expected result — click to reveal
1043web01 | SUCCESS => {
"ansible_facts": {
"ansible_distribution": "Ubuntu",
"ansible_distribution_file_parsed": true,
"ansible_distribution_file_path": "/etc/os-release",
"ansible_distribution_file_variety": "Debian",
"ansible_distribution_major_version": "22",
"ansible_distribution_release": "jammy",
"ansible_distribution_version": "22.04"
},
"changed": false
}"ansible_memfree_mb": 5122,
"ansible_memory_mb": {
"nocache": {"free": 6903, "used": 1033},
"real": {"free": 5122, "total": 7936, "used": 2814},
"swap": {"cached": 0, "free": 0, "total": 0, "used": 0}
},
"ansible_memtotal_mb": 7936"ansible_default_ipv4": {
"address": "10.0.1.15",
"alias": "eth0",
"gateway": "10.0.1.1",
"interface": "eth0",
"macaddress": "06:1a:2b:3c:4d:5e",
"netmask": "255.255.255.0",
"network": "10.0.1.0"
}Over a thousand lines per host. That is the cost of fact gathering, and it is why gather_facts: false is a real optimisation in plays that never use a fact.
Note ansible_default_ipv4 is a dictionary, which is why you write ansible_default_ipv4.address and not just ansible_default_ipv4. Same for ansible_memory_mb — nested dicts all the way down. filter= is how you explore without drowning.
🧪 Exercise B1.2 — Measure what fact gathering actually costs
time ansible all -m ansible.builtin.ping # gathers nothing
time ansible-playbook facts-on.yml # gather_facts: true
time ansible-playbook facts-off.yml # gather_facts: false# facts-on.yml
- hosts: all
gather_facts: true
tasks:
- ansible.builtin.debug: msg="hello"
# facts-off.yml — identical, but:
- hosts: all
gather_facts: false
tasks:
- ansible.builtin.debug: msg="hello"✅ Expected result — click to reveal
facts-on.yml real 0m6.412s
facts-off.yml real 0m1.883sRoughly 4.5 seconds of pure overhead on a handful of hosts, for a play that used no facts at all. Fact gathering is a full extra module execution per host — the entire ten-step lifecycle from Module 01 Part B6, just to collect data you are not going to read.
Three ways to control it, in increasing sophistication:
gather_facts: false # the play uses no ansible_* variable
gather_subset: "!all,!min,network" # gather only what you need# ansible.cfg — gather once, reuse across runs
[defaults]
fact_caching = jsonfile
fact_caching_connection = /tmp/ansible_facts
fact_caching_timeout = 7200The interview framing: on a 500-host fleet this is not 4 seconds, it is minutes on every single run, including runs triggered by CI on every merge. Being able to name gather_subset and fact caching as the fixes — not just gather_facts: false — is what marks the difference between having read about it and having tuned it.
🎯 Interview questions — Facts
Q. What are Ansible facts and where do they come from?
Variables automatically discovered about each managed host by the setup module, which runs at the start of any play with gather_facts: true (the default). They cover OS, distribution, network interfaces, CPU, memory, mounts, service manager and more.
They are accessed either as ansible_facts['distribution'] (modern, namespaced, preferred) or the injected flat form ansible_distribution.
Q. What does fact gathering cost, and how do you reduce it?
It is a full extra module execution per host — over a thousand data points, one complete SSH round trip. On a large fleet that is minutes per run.
Three fixes, escalating: gather_facts: false for plays that reference no ansible_* variable; gather_subset to collect only the categories you need, for example "!all,!min,network"; and fact caching via jsonfile or redis, so facts are gathered once and reused across runs within the cache timeout.
Q. ansible_distribution or ansible_os_family — which for a cross-distro conditional?
ansible_os_family. It groups related distributions: Debian covers Debian and Ubuntu, RedHat covers RHEL, CentOS, Rocky and Alma.
Using ansible_distribution means enumerating every distro name individually, and the playbook breaks the first time someone deploys on a variant you did not list.
Better still, avoid the conditional entirely where a portable module such as package already handles the difference.
Q. What is inject_facts_as_vars?
The ansible.cfg setting that controls whether facts are also exposed as top-level ansible_* variables. Default true.
Setting it to false means only ansible_facts['...'] works — which avoids any chance of your own variables colliding with fact names, but breaks every playbook written in the flat style.
The practical takeaway: write ansible_facts['distribution'] in new code so you are not dependent on the injection.
B2 · Custom facts — facts.d
Custom facts are that note. They let the server declare things Ansible could never work out by inspecting it: which team owns it, which customer it serves, when its maintenance window is. Measured facts come from the examination; custom facts come from someone writing it down.
Beyond what Ansible discovers, you can make a host advertise its own facts. Drop a file in /etc/ansible/facts.d/ on the managed node and it appears under ansible_local.
Static custom facts — a .fact file in INI or JSON
# /etc/ansible/facts.d/deployment.fact (INI format)
[app]
version=2.4.1
tier=production
owner=platform-team# Accessed as:
"{{ ansible_local.deployment.app.version }}" # -> 2.4.1
# ^ ^ ^ ^
# | | | +-- the key
# | | +------ the INI section
# | +----------------- the filename without .fact
# +---------------------------- always ansible_localDynamic custom facts — any executable that prints JSON
#!/bin/bash
# /etc/ansible/facts.d/hardware.fact -- must be executable (0755)
echo "{\"disk_count\": $(lsblk -d -n | wc -l), \"kernel\": \"$(uname -r)\"}""{{ ansible_local.hardware.disk_count }}"This is a strong thing to bring up unprompted; most candidates have never used custom facts and it signals real production exposure.
🧪 Exercise B2.1 — Deploy a custom fact and read it back
---
- name: Set up custom facts
hosts: web01
become: true
tasks:
- name: Ensure the facts.d directory exists
ansible.builtin.file:
path: /etc/ansible/facts.d
state: directory
mode: "0755"
- name: Deploy a static custom fact
ansible.builtin.copy:
dest: /etc/ansible/facts.d/deployment.fact
mode: "0644"
content: |
[app]
version=2.4.1
tier=production
- name: Re-gather facts so the new one is visible NOW
ansible.builtin.setup:
filter: ansible_local
- name: Read it back
ansible.builtin.debug:
msg: "Tier is {{ ansible_local.deployment.app.tier }}, version {{ ansible_local.deployment.app.version }}"✅ Expected result — click to reveal
TASK [Ensure the facts.d directory exists] ************* changed: [web01]
TASK [Deploy a static custom fact] ********************* changed: [web01]
TASK [Re-gather facts so the new one is visible NOW] *** ok: [web01]
TASK [Read it back] ************************************
ok: [web01] => {
"msg": "Tier is production, version 2.4.1"
}The third task is the one that matters, and it is the one people omit. Facts were gathered at the start of the play, before the .fact file existed. Without explicitly re-running setup, ansible_local.deployment would be undefined and the play would fail — even though the file is sitting right there on disk.
This is the same class of ordering problem as meta: flush_handlers in Module 01 Part D3: Ansible captured state at a point in time, and you changed that state afterwards. Recognising that pattern is worth more than memorising either individual fix.
Confirm on the target:
ssh web01 'cat /etc/ansible/facts.d/deployment.fact'
ansible web01 -m ansible.builtin.setup -a 'filter=ansible_local'🎯 Interview questions — Custom facts
Q. What are custom facts and how do you create them?
Facts the host itself advertises, defined in /etc/ansible/facts.d/ on the managed node and exposed under ansible_local.
Two forms: a static .fact file in INI or JSON, or any executable that prints JSON to stdout, which lets a fact be computed at gather time.
Accessed as ansible_local.<filename>.<section>.<key>.
The use case worth naming: letting a host declare things Ansible cannot infer — its tier, its owning team, its patch window, its compliance class — so playbooks stop hard-coding hostnames.
Q. You deploy a custom fact in a play and read it two tasks later. It is undefined. Why?
Facts were gathered at the start of the play, before the file existed. The fact cache for that host is a snapshot.
Fix by re-running the setup module explicitly mid-play — ideally with filter: ansible_local so you re-gather only what changed.
It is the same category of problem as a handler running after the task that verifies it: state captured at one point, changed at another.
B3 · set_fact vs register vs vars
vars: is the note from home, register is the receipt, and set_fact is the sticky note. That is why a registered result gives you rc, stdout and changed rather than just the one value you wanted — receipts come as they come. And the two you picked up along the way stay in your pocket for the rest of the trip, while the note from home gets binned at the end of the day: that is precisely the difference between host-scoped and play-scoped.
Three ways a value comes to exist at run time, and interviewers love asking you to distinguish them.
Diagram source
flowchart TD
A["vars: in the playbook"] -->|"defined before the run<br>static, you typed it"| Z["Available to tasks"]
B["gathered facts"] -->|"discovered by setup<br>at play start"| Z
C["register: on a task"] -->|"captures that task's<br>FULL result object"| Z
D["set_fact"] -->|"you compute and<br>name a value mid-play"| Z
Z --> E{"Scope?"}
E -->|"vars"| F["play-scoped"]
E -->|"facts / register / set_fact"| G["HOST-scoped<br>survives to later plays<br>in the same run"]
style D fill:#DDD6FE,stroke:#7C3AED,stroke-width:2px
style G fill:#FEF3C7,stroke:#D97706,stroke-width:2pxregister — capture what a task returned
- name: Check whether the app is deployed
ansible.builtin.stat:
path: /opt/app/current
register: app_dir
- name: Deploy only if it is missing
ansible.builtin.command: /opt/deploy.sh
when: not app_dir.stat.existsA registered variable is the module's entire result object, not just a value. Common keys:
| Key | Contains |
|---|---|
| .changed | did the task change anything |
| .failed | did it fail |
| .skipped | was it skipped by a when: |
| .rc | exit code — command/shell only |
| .stdout / .stderr | output as one string |
| .stdout_lines | the same output already split into a list — usually what you actually want |
| .results | a list of result objects, when the task used a loop |
set_fact — compute and name a value
- name: Build the release directory name
ansible.builtin.set_fact:
release_dir: "/opt/app/releases/{{ app_version }}-{{ ansible_date_time.epoch }}"
is_production: "{{ 'prod' in group_names }}"
- name: Use it
ansible.builtin.file:
path: "{{ release_dir }}"
state: directory
mode: "0755"And both sit at level 19 of the precedence ladder, near the top — high enough to override almost everything except role params, include params and extra vars.
🧪 Exercise B3.1 — Inspect a full registered object
---
- name: Registered variable anatomy
hosts: web01
gather_facts: false
tasks:
- name: Run a command
ansible.builtin.shell: |
echo "line one"
echo "line two"
register: result
changed_when: false
- name: Show the ENTIRE object
ansible.builtin.debug:
var: result
- name: Show just the useful parts
ansible.builtin.debug:
msg: "rc={{ result.rc }} | first line={{ result.stdout_lines[0] }} | changed={{ result.changed }}"✅ Expected result — click to reveal
TASK [Show the ENTIRE object] **************************************
ok: [web01] => {
"result": {
"changed": false,
"cmd": "echo \"line one\"\necho \"line two\"\n",
"delta": "0:00:00.004521",
"end": "2026-08-14 12:31:07.882314",
"failed": false,
"rc": 0,
"start": "2026-08-14 12:31:07.877793",
"stderr": "",
"stderr_lines": [],
"stdout": "line one\nline two",
"stdout_lines": [
"line one",
"line two"
]
}
}
TASK [Show just the useful parts] **********************************
ok: [web01] => {
"msg": "rc=0 | first line=line one | changed=False"
}debug: var=result is the single most useful debugging habit in Ansible. Before writing when: result.something, dump the whole object once and look at what is actually in it. Guessing key names is how people waste afternoons.
Note stdout_lines — Ansible has already split the output into a list for you. Reaching for result.stdout.split('\n') is the beginner move; stdout_lines is right there.
Also note changed=False even though this was a shell task — because changed_when: false was set. That is the Module 01 idempotency lesson applied.
🧪 Exercise B3.2 — Prove that set_fact outlives the play
---
- name: Play one
hosts: web01
gather_facts: false
tasks:
- name: A play-scoped var and a host-scoped fact
ansible.builtin.set_fact:
my_fact: "I am a fact"
vars:
my_var: "I am a play var"
- name: Play two - same host, different play
hosts: web01
gather_facts: false
tasks:
- name: Can I still see the fact?
ansible.builtin.debug:
msg: "{{ my_fact }}"
- name: Can I still see the play var?
ansible.builtin.debug:
msg: "{{ my_var | default('*** GONE ***') }}"✅ Expected result — click to reveal
PLAY [Play two - same host, different play] ************************
TASK [Can I still see the fact?] ***********************************
ok: [web01] => {
"msg": "I am a fact"
}
TASK [Can I still see the play var?] *******************************
ok: [web01] => {
"msg": "*** GONE ***"
}This is the scoping rule made visible. set_fact attaches the value to the host, so it persists for the rest of the run. vars: is scoped to the play and evaporates when the play ends.
Why it matters practically: it is how you pass information between plays. Play one queries a database for the current leader node and set_facts it; play two, targeting a different host group, reads it via hostvars. There is no other clean mechanism for that.
⚠️ One caveat worth knowing: set_fact values are not written to the fact cache by default — they live only for the current run. Add cacheable: true if you need them persisted across separate ansible-playbook invocations.
🎯 Interview questions — set_fact, register & scope
Q. What is the difference between set_fact and register?
register captures the complete result object a module returned — changed, failed, rc, stdout, stdout_lines, and for looped tasks a results list.
set_fact assigns a value you computed, typically from other variables, filters or facts.
Both are host-scoped and both persist for that host across subsequent plays in the same run, and both sit at level 19 of the precedence ladder.
Q. What is the difference in scope between vars: and set_fact?
vars: is play-scoped — it exists only within the play that defines it and disappears when that play ends.
set_fact is host-scoped — attached to the host and available in every later play of the same run.
That is precisely how you pass data between plays: set_fact in play one, then read it from another host group in play two via hostvars.
Q. How do you use the result of one task to decide whether another runs?
register the first task, then use a when: referencing the registered object — when: result.rc != 0, when: not stat_result.stat.exists, when: "'ERROR' in result.stdout".
The practical advice: run debug: var=result once first and look at the real structure rather than guessing key names. And prefer stdout_lines over splitting stdout yourself.
Q. Does set_fact survive between separate playbook runs?
No, not by default — it lives for the current run only. Pass cacheable: true and it is written to the fact cache, which then persists across invocations subject to fact_caching_timeout.
Worth flagging the trade-off: cached values can go stale, and a stale cached fact is a genuinely unpleasant bug because the playbook looks correct.
B4 · Magic variables
Magic variables are that background knowledge. Each server already knows its own name as inventory_hostname, the groups it belongs to as group_names, and the whole school as groups. And hostvars is one child turning round to ask another for their answer — which is exactly how a web server looks up the database server's IP address without anyone hard-coding it.
Ansible always provides a set of variables describing the run itself — not the host's hardware, but the inventory, the play, and the other hosts in it. You met inventory_hostname in Module 01 Exercise D4.1.
| Variable | What it holds |
|---|---|
| inventory_hostname | The current host's inventory name — web01, not necessarily its real hostname |
| inventory_hostname_short | Everything before the first dot |
| group_names | List of groups this host belongs to |
| groups | Dict of every group in the inventory and its members |
| hostvars | Dict giving access to any other host's variables and facts |
| ansible_play_hosts | Hosts still active in this play (failed ones are removed) |
| ansible_play_batch | Hosts in the current serial batch |
| ansible_playbook_python | Python interpreter on the control node |
| playbook_dir | Absolute path of the playbook's directory — useful for building file paths |
| ansible_check_mode | Boolean — is this a --check run |
hostvars — the one that unlocks orchestration
hostvars lets a task running on one host read another host's facts. This is how you write a config file on the web tier that contains the database server's IP.
- name: Configure the app to point at the database
ansible.builtin.copy:
dest: /etc/myapp/db.conf
mode: "0644"
content: |
db_host={{ hostvars['db01']['ansible_default_ipv4']['address'] }}
db_port=5432# Build an nginx upstream block from every host in the web group
{% for host in groups['web'] %}
server {{ hostvars[host]['ansible_default_ipv4']['address'] }}:8080;
{% endfor %}The fixes: add a preliminary play - hosts: db with gather_facts: true and no tasks, or use delegate_facts, or enable fact caching so previously gathered facts persist.
This is asked frequently because it separates people who have used hostvars from people who have read about it.
🧪 Exercise B4.1 — Explore the magic variables on your own inventory
---
- name: Magic variable tour
hosts: web
gather_facts: false
tasks:
- name: Who am I and where do I belong?
ansible.builtin.debug:
msg: >-
I am {{ inventory_hostname }} |
groups: {{ group_names }} |
active in play: {{ ansible_play_hosts }}
- name: What does the whole inventory look like?
ansible.builtin.debug:
var: groups
run_once: true✅ Expected result — click to reveal
TASK [Who am I and where do I belong?] *****************************
ok: [web01] => {
"msg": "I am web01 | groups: ['prod', 'web'] | active in play: ['web01', 'web02', 'web03']"
}
ok: [web02] => {
"msg": "I am web02 | groups: ['prod', 'web'] | active in play: ['web01', 'web02', 'web03']"
}
TASK [What does the whole inventory look like?] ********************
ok: [web01] => {
"groups": {
"all": ["web01", "web02", "web03", "db01", "stg01"],
"db": ["db01"],
"prod": ["web01", "web02", "web03", "db01"],
"staging": ["stg01"],
"ungrouped": [],
"web": ["web01", "web02", "web03"]
}
}Three things to read out of this:
- group_names shows ['prod', 'web'] — the host is in both, because web is a child of prod. That is the group inheritance from Module 01 Exercise B1.1, now visible as data.
- groups contains the entire inventory, including hosts this play is not targeting. That is what makes hostvars['db01'] possible — Ansible knows db01 exists even when the play does not touch it. Knowing it exists is not the same as having its facts.
- run_once: true printed it a single time instead of once per host. Without it you get the same large dictionary three times.
Also note >- in the YAML — a folded block scalar that lets a long string span lines while stripping the trailing newline. Handy for readable msg: values.
🧪 Exercise B4.2 — Hit the hostvars trap, then fix it
---
# BROKEN - the play never touches db01, so its facts do not exist
- name: Configure web to point at db
hosts: web
gather_facts: true
tasks:
- name: Try to read db01's IP
ansible.builtin.debug:
msg: "DB is at {{ hostvars['db01']['ansible_default_ipv4']['address'] }}"✅ Expected result — and both fixes — click to reveal
TASK [Try to read db01's IP] ***************************************
fatal: [web01]: FAILED! => {
"msg": "The task includes an option with an undefined variable.
The error was: \"hostvars['db01']\" is undefined"
}Fix A — a preliminary fact-gathering play. The idiomatic answer:
- name: Gather facts from the database tier
hosts: db
gather_facts: true
tasks: [] # no tasks - the point is purely the fact gathering
- name: Configure web to point at db
hosts: web
gather_facts: true
tasks:
- name: Read db01's IP
ansible.builtin.debug:
msg: "DB is at {{ hostvars['db01']['ansible_default_ipv4']['address'] }}"ok: [web01] => {
"msg": "DB is at 10.0.2.11"
}Fix B — fact caching. With fact_caching enabled in ansible.cfg, db01's facts from any earlier run are still available and no preliminary play is needed. Faster, but the values can be stale.
Fix C — avoid facts entirely. If all you need is an address you already know, put it in the inventory as a host var. hostvars['db01']['db_ip'] works with no gathering at all, because inventory variables for every host are always loaded. Often the simplest correct answer, and worth saying so.
🎯 Interview questions — Magic variables
Q. What are magic variables? Name several.
Variables Ansible always provides describing the run itself rather than the host's hardware.
inventory_hostname (the host's inventory name), group_names (groups this host is in), groups (every group and its members), hostvars (access to other hosts' variables and facts), ansible_play_hosts (hosts still active in the play), playbook_dir, and ansible_check_mode.
Q. How does a task on a web server get the database server's IP address?
hostvars['db01']['ansible_default_ipv4']['address'].
The critical caveat: db01's facts must have been gathered in this run, otherwise it is undefined. Fix with a preliminary - hosts: db play that gathers facts and has no tasks, or enable fact caching, or — often simplest — store the address as an inventory host variable so no gathering is required at all.
Q. inventory_hostname vs ansible_hostname — what is the difference?
inventory_hostname is the name in your inventory file — a label you chose, available with no fact gathering.
ansible_hostname is a gathered fact: the machine's actual configured hostname, which requires gather_facts and can differ entirely from the inventory name.
In a cloud inventory keyed by private IP, inventory_hostname might be 10.0.1.15 while ansible_hostname is ip-10-0-1-15.
Part C · Precedence — the 22-level ladder
C1 · The ladder
Back to the wedding. Everyone told you a different time, so who do you actually believe?
The rule most families use: the more specific and more senior the instruction, the more it counts. A general note printed on 200 invitations is the weakest. A direct message from the groom, to you, an hour ago, beats everything.
Ansible's 22 levels are just that idea, written down precisely. Role defaults are the printed invitation. Extra vars (-e) are the groom texting you directly.
The same variable name can be defined in a dozen places. Ansible resolves it with a strict order — lowest priority first, highest last.
Diagram source
flowchart BT
L1["1 · command line values (-u, -c)"] --> L2["2 · role defaults<br>LOWEST real variable"]
L2 --> L3["3-4 · inventory group vars<br>+ inventory group_vars/all"]
L3 --> L5["5 · playbook group_vars/all"]
L5 --> L6["6-7 · inventory + playbook group_vars/*"]
L6 --> L8["8-10 · host vars<br>inventory, then host_vars/*"]
L8 --> L11["11 · host facts / cached set_facts"]
L11 --> L12["12-14 · play vars, vars_prompt, vars_files"]
L12 --> L15["15 · role vars (vars/main.yml)"]
L15 --> L16["16-17 · block vars, then task vars"]
L16 --> L18["18 · include_vars"]
L18 --> L19["19 · set_fact / registered vars"]
L19 --> L20["20-21 · role params, include params"]
L20 --> L22["22 · EXTRA VARS -e<br>ALWAYS WINS"]
style L2 fill:#F1F5F9,stroke:#64748B,stroke-width:2px
style L22 fill:#FEE2E2,stroke:#DC2626,stroke-width:3px📋 The full 22 levels as a list — click to reveal
Lowest priority at the top, highest at the bottom. Later entries override earlier ones.
1. command line values (e.g. -u my_user — not really variables)
2. role defaults (role/defaults/main.yml)
3. inventory file or script group vars
4. inventory group_vars/all
5. playbook group_vars/all
6. inventory group_vars/*
7. playbook group_vars/*
8. inventory file or script host vars
9. inventory host_vars/*
10. playbook host_vars/*
11. host facts / cached set_facts
12. play vars
13. play vars_prompt
14. play vars_files
15. role vars (role/vars/main.yml)
16. block vars (only for tasks in the block)
17. task vars (only for that task)
18. include_vars
19. set_fact / registered vars
20. role params and include_role params
21. include params
22. extra vars (-e) — ALWAYS WIN🔻 Role defaults are the floor — designed to be overridden, which is exactly why defaults/ exists.
🔺 Extra vars are the ceiling — -e beats everything, always.
📍 In between, more specific beats more general: host beats group, child group beats parent group, task beats block beats play.
⚡ role vars (level 15) is much higher than role defaults (level 2) — the single most useful thing to know about writing roles.
Say those four things and you have answered the question better than most candidates.
defaults/ vs vars/ in a role — the practical consequence
Sensible defaults meant to be overridden by whoever uses the role.
Put almost everything here.
Internal constants the user should not change. Beats inventory, beats group_vars, beats host_vars.
Use sparingly.
🧪 Exercise C1.1 — Watch higher precedence win, level by level
mkdir -p group_vars host_vars
printf 'greeting: from_group_vars\n' > group_vars/web.yml
printf 'greeting: from_host_vars\n' > host_vars/web01.yml# precedence.yml
---
- name: Precedence demo
hosts: web01
gather_facts: false
vars:
greeting: from_play_vars
tasks:
- name: Which one won?
ansible.builtin.debug:
msg: "{{ greeting }}"
- name: Task vars beat play vars
ansible.builtin.debug:
msg: "{{ greeting }}"
vars:
greeting: from_task_varsRun it three times:
ansible-playbook precedence.yml
ansible-playbook precedence.yml -e greeting=from_extra_vars
ansible-playbook precedence.yml -e greeting=from_extra_vars --limit web01✅ Expected result — click to reveal
Run 1 — no extra vars:
TASK [Which one won?] **********************************************
ok: [web01] => {"msg": "from_play_vars"}
TASK [Task vars beat play vars] ************************************
ok: [web01] => {"msg": "from_task_vars"}Play vars (level 12) beat host_vars (level 10), which beat group_vars (level 6). Task vars (level 17) beat everything so far.
Run 2 — with -e:
TASK [Which one won?] **********************************************
ok: [web01] => {"msg": "from_extra_vars"}
TASK [Task vars beat play vars] ************************************
ok: [web01] => {"msg": "from_extra_vars"}Look carefully at the second task in run 2. It has vars: greeting: from_task_vars written directly on it — and it still printed from_extra_vars.
That is the meaning of "extra vars always win." Not "usually", not "unless overridden locally". There is no way to beat -e from inside a playbook. That property is what makes -e both powerful and dangerous: a stray -e env=prod in a CI job overrides every safeguard you wrote into the playbook.
🎯 Interview questions — Precedence
Q. Explain Ansible's variable precedence.
There are 22 documented levels, resolved lowest to highest, but the shape is what matters:
Role defaults are the lowest real variable source — they exist to be overridden.
Extra vars (-e) are the highest and always win, with no exception.
In between, more specific beats more general: host vars beat group vars, child groups beat parent groups, and task vars beat block vars beat play vars.
One asymmetry worth calling out: a role's vars/main.yml (level 15) sits far above its defaults/main.yml (level 2) — high enough to override inventory and group_vars, which surprises people.
Q. When would you put a variable in a role's defaults/ versus its vars/?
defaults/ for anything a user of the role might legitimately want to change — ports, versions, paths, feature flags. It is the lowest precedence, so inventory and group_vars can override it, which is the whole point.
vars/ for internal constants the role depends on and the user should not touch. It is level 15, above inventory and group_vars, so it cannot be overridden from there.
The rule of thumb: almost everything belongs in defaults/. Putting a user-facing setting in vars/ produces silent, hours-long debugging sessions where someone's group_vars change appears to do nothing.
Q. A variable is set in group_vars and in the role's vars/main.yml. Which wins?
Role vars, at level 15, beat inventory group_vars at level 6.
And this is usually a bug in the role's design rather than intended behaviour — if the value is one a consumer would want to set, it should have been in defaults/.
Q. Can anything override extra vars?
No. Level 22 is the ceiling — -e cannot be overridden from inside a playbook, not even by a task-level vars:.
The operational implication worth stating: -e bypasses every safeguard written into the playbook, so it should be used deliberately and, in CI, sparingly. It is convenient for a one-off override and dangerous as a habit.
C2 · group_vars/ and host_vars/ — where variables should actually live
group_vars/ and host_vars/ are those folders: anything that applies to all the web servers goes in the web folder, and anything for one specific machine goes in that machine's folder. And the label has to match exactly. A folder marked Car Insurance is no help when you go hunting under Vehicle — which is why a group_vars file whose name does not match a real group is silently ignored, with no error at all. That one detail causes more wasted afternoons than any other in this module.
In any real project you do not put variables in the inventory file. You put them in directories beside it, one file per group or host.
ansible-project/
ansible.cfg
site.yml
inventory/
hosts.yml
group_vars/
all.yml <- applies to every host
web.yml <- applies to the web group
db.yml
prod/ <- a DIRECTORY also works
vault.yml <- encrypted secrets
common.yml <- everything in prod/ is merged
host_vars/
web01.yml <- applies to web01 only
db01.yml
roles/Two locations, two priorities
There are two valid places for these directories, and they are not equivalent:
- Beside the inventory → inventory/group_vars/web.yml (levels 3–4, 6, 8–9)
- Beside the playbook → ./group_vars/web.yml (levels 5, 7, 10)
Playbook-adjacent wins. Most projects should pick one and stay consistent — mixing them is a reliable way to confuse everyone including yourself.
Group priority when a host is in several groups
[web]
web01
[canary]
web01
[web:vars]
deploy_batch=standard
[canary:vars]
deploy_batch=first
ansible_group_priority=10 # higher number wins; default is 1| # | Rule, applied in this order |
|---|---|
| 1 | Child groups beat parent groups — always, regardless of priority |
| 2 | At the same level, higher ansible_group_priority wins (default 1) |
| 3 | If priorities tie, resolution is alphabetical by group name — and the last one alphabetically wins |
| 4 | Host vars always beat all group vars, whatever their priority |
ansible_group_priority exists precisely to make that deterministic. Note it is set on the group, and it is not itself inheritable by child groups.
🧪 Exercise C2.1 — Trigger the alphabetical tie-break
# inventory/hosts.ini
[alpha]
web01
[zulu]
web01
[alpha:vars]
winner=alpha_won
[zulu:vars]
winner=zulu_wonansible -i inventory/hosts.ini web01 -m ansible.builtin.debug -a "var=winner"Then add ansible_group_priority=10 under [alpha:vars] and run it again.
✅ Expected result — click to reveal
Before setting a priority:
web01 | SUCCESS => {
"winner": "zulu_won"
}zulu won purely because z sorts after a. Nothing about the configuration expressed that intent — it is an accident of naming.
After adding ansible_group_priority=10 to [alpha:vars]:
web01 | SUCCESS => {
"winner": "alpha_won"
}Now the outcome is declared rather than incidental. This is a small feature that almost nobody uses and that produces genuinely mystifying bugs when they need it and do not know it exists.
The stronger design point, worth making in an interview: relying on group priority at all is usually a smell. If two groups fight over a value, the cleaner fix is often to restructure — make one a child of the other, so rule 1 settles it explicitly and readably, rather than tuning priority numbers.
🧪 Exercise C2.2 — See every resolved variable for a host
ansible-inventory -i inventory/ --host web01
ansible-inventory -i inventory/ --list
ansible-inventory -i inventory/ --graph --vars✅ Expected result — click to reveal
{
"ansible_host": "10.0.1.15",
"ansible_user": "deploy",
"deploy_batch": "first",
"http_port": 80,
"winner": "alpha_won"
}@all:
|--@prod:
| |--@web:
| | |--web01
| | | |--{ansible_host = 10.0.1.15}
| | | |--{http_port = 80}
| | |--{deploy_batch = standard}ansible-inventory --host <name> is the fastest possible answer to "what value does this host actually have?" It resolves everything from the inventory layer and prints the result, with no connection to the host at all.
⚠️ Important limitation to state if asked: this shows the inventory-derived variables only. It cannot show play vars, role vars, facts, or set_fact values, because those exist only during a play. For the full runtime picture you need debug: var=... inside the play itself, or -vvv.
🎯 Interview questions — group_vars, host_vars, group priority
Q. Where should variables live in a real project?
In group_vars/ and host_vars/ directories, one file per group or host, rather than inline in the inventory file. group_vars/all.yml for global defaults, then per-group files, then host_vars/ for genuine per-host exceptions.
The filename must exactly match the group or host name — a mismatch is ignored silently with no warning.
A directory such as group_vars/prod/ also works and merges every file inside it, which is how teams separate plain config from Vault-encrypted secrets.
Q. A host is in two groups that both define the same variable. Which wins?
First: a child group beats its parent, always.
Between siblings at the same level: the higher ansible_group_priority wins, default 1.
If priorities tie, it falls back to alphabetical order by group name, last one winning — which is non-obvious and a real source of bugs, because renaming a group can silently change behaviour.
And host vars beat all group vars regardless.
Good closing thought: if two groups are fighting over a value, restructuring so one is a child of the other is usually cleaner than tuning priority numbers.
Q. How do you find out what value a variable actually has for a given host?
ansible-inventory --host web01 shows everything resolved from the inventory layer, without connecting to anything.
For runtime values — facts, set_fact, play vars — use debug: var=myvar inside the play, or debug: var=hostvars[inventory_hostname] to dump the host's entire variable space at once.
-vvv also reveals the resolved arguments a module received, which catches templating mistakes.
Part D · Working with variables safely
D1 · Undefined variables and default()
The substitute is default(). Stopping and naming the problem is mandatory. Skipping the step is when: … is defined. What you must never do is add nothing, say nothing and carry on — because then the dish comes out wrong and nobody knows why. That is exactly what a careless default('') does: it hides the missing value instead of handling it.
An undefined variable is a fatal error by default:
fatal: [web01]: FAILED! => {"msg": "The task includes an option with an
undefined variable. The error was: 'app_version' is undefined"}# Supply a fallback
"{{ app_version | default('1.0.0') }}"
# Treat an EMPTY value as also needing the default (note the second argument)
"{{ app_version | default('1.0.0', true) }}"
# Require it explicitly, with a clear message
"{{ app_version | mandatory }}"
# Guard a whole task
- name: Deploy
ansible.builtin.command: /opt/deploy.sh
when: app_version is defined
# Other useful tests
when: app_version is not defined
when: app_version is none
when: some_list | length > 0Plain default('x') only substitutes when the variable is undefined. If it is defined but empty ("") or false or 0, you get the empty value through.
default('x', true) substitutes whenever the value is falsy — undefined, empty string, false, 0, or an empty list.
For anything user-supplied, the second form is almost always what you actually meant.
🧪 Exercise D1.1 — Every branch of default() in one run
---
- name: default() behaviour
hosts: localhost
gather_facts: false
vars:
empty_string: ""
zero_value: 0
real_value: "actual"
tasks:
- ansible.builtin.debug:
msg:
- "undefined, default() -> {{ nonexistent | default('FALLBACK') }}"
- "empty string, default() -> '{{ empty_string | default('FALLBACK') }}'"
- "empty string, default(x,true)-> '{{ empty_string | default('FALLBACK', true) }}'"
- "zero, default() -> {{ zero_value | default('FALLBACK') }}"
- "zero, default(x,true)-> {{ zero_value | default('FALLBACK', true) }}"
- "real value, default() -> {{ real_value | default('FALLBACK') }}"✅ Expected result — click to reveal
ok: [localhost] => {
"msg": [
"undefined, default() -> FALLBACK",
"empty string, default() -> ''",
"empty string, default(x,true)-> 'FALLBACK'",
"zero, default() -> 0",
"zero, default(x,true)-> FALLBACK",
"real value, default() -> actual"
]
}Rows 2 and 4 are the ones that catch people. An empty string and a zero are defined, so plain default() passes them straight through. You end up writing an empty value into a config file and wondering why the service will not start.
⚠️ But note row 5 is a genuine trap in the other direction. default('FALLBACK', true) replaced a legitimate 0. If your variable is retry_count: 0 or enable_feature: false, the boolean form will silently override a deliberate falsy setting.
The rule: use default(x, true) for strings that must not be empty; use plain default(x) for numbers and booleans where 0 and false are meaningful values.
🎯 Interview questions — Undefined variables
Q. How do you handle a variable that might not be defined?
{{ myvar | default('fallback') }} to supply a value, {{ myvar | mandatory }} to fail loudly and clearly if it is missing, or when: myvar is defined to skip the task entirely.
The nuance to add: default('x') only triggers on undefined. Use default('x', true) to also catch empty strings — but not for numbers or booleans, where it would wrongly replace a legitimate 0 or false.
Q. Where is mandatory better than default?
When there is no sensible default and running without the value would do something wrong. mandatory fails fast with a clear message naming the variable, rather than proceeding with a placeholder that silently produces a broken configuration.
A common example: a deployment version. Defaulting it to latest is worse than refusing to run.
D2 · Merging dictionaries — combine and hash_behaviour
When the same dictionary variable is defined at two precedence levels, the default behaviour is replace, not merge:
# group_vars/all.yml
app_config:
host: 0.0.0.0
port: 8080
debug: false
# host_vars/web01.yml
app_config:
port: 9090# The result for web01 is NOT what most people expect:
{"port": 9090} # host and debug are GONE - the whole dict was replacedTwo ways to fix it
# FIX A (recommended) - merge explicitly with the combine filter
- name: Merge config layers
ansible.builtin.set_fact:
final_config: "{{ app_config_defaults | combine(app_config_overrides, recursive=True) }}"# FIX B (discouraged) - change the global behaviour in ansible.cfg
[defaults]
hash_behaviour = mergePrefer explicit combine() — it is visible at the point of use rather than hidden in a config file.
🧪 Exercise D2.1 — Watch a dictionary get destroyed, then merge it properly
---
- name: Dictionary merge behaviour
hosts: localhost
gather_facts: false
vars:
base_config:
host: 0.0.0.0
port: 8080
debug: false
tls:
enabled: true
cert: /etc/ssl/default.pem
override_config:
port: 9090
tls:
cert: /etc/ssl/custom.pem
tasks:
- name: Shallow merge - one level only
ansible.builtin.debug:
var: base_config | combine(override_config)
- name: Recursive merge - all levels
ansible.builtin.debug:
var: base_config | combine(override_config, recursive=True)✅ Expected result — click to reveal
Shallow merge:
{
"debug": false,
"host": "0.0.0.0",
"port": 9090,
"tls": {
"cert": "/etc/ssl/custom.pem"
}
}Recursive merge:
{
"debug": false,
"host": "0.0.0.0",
"port": 9090,
"tls": {
"cert": "/etc/ssl/custom.pem",
"enabled": true
}
}Compare the tls block. The shallow merge kept top-level keys but replaced the entire nested tls dictionary, losing enabled: true. Only recursive=True merged inside the nested level.
This is the bug in miniature. Shallow-merging a config with nested TLS settings silently drops enabled: true, TLS quietly turns off, and nothing in the output suggests anything went wrong. When merging any dictionary that has nested dictionaries, you almost always want recursive=True.
🎯 Interview questions — Merging
Q. What happens when the same dictionary is defined at two precedence levels?
By default the higher-precedence definition replaces the whole dictionary — keys present only in the lower-precedence version are lost. It does not merge.
The fix is the combine filter, and recursive=True when the dictionary has nested dictionaries, otherwise nested levels are replaced wholesale.
There is also a global hash_behaviour = merge setting, but it is deprecated and changes semantics for every dictionary in the project — including inside imported Galaxy roles that were written expecting replace.
Q. Why is hash_behaviour = merge considered bad practice?
It changes behaviour globally and invisibly. Any role written and tested against the default replace semantics may behave differently, and the same playbook produces different results on a machine with a different ansible.cfg.
combine() is explicit at the point of use, reviewable in a diff, and scoped to the one place you meant it.
D3 · Prompting and loading from files
---
- name: vars_prompt and vars_files
hosts: web
vars_files:
- vars/common.yml
- "vars/{{ env }}.yml" # dynamic filename - resolved at run time
vars_prompt:
- name: app_version
prompt: "Which version are you deploying?"
private: false # visible while typing
- name: db_password
prompt: "Database password"
private: true # hidden - the default
confirm: true # ask twice and compare
tasks:
- ansible.builtin.debug:
msg: "Deploying {{ app_version }}"The automation-friendly alternatives: -e for a one-off, Ansible Vault for secrets (Module 07), or an AWX Survey, which presents the same prompt through a web form and passes the answer in as extra vars.
D4 · omit, environment variables and lookup
omit leaves the box blank — the argument is never passed to the module at all. default('') writes "none" in it, and the module then dutifully tries to act on an empty value and fails. This is the reason omit exists, and it is a good, short answer to give when an interviewer asks about optional module arguments.
Three things that come up constantly in real playbooks and are almost never taught.
omit — the magic placeholder for "do not pass this argument at all"
Sometimes you want a module argument to be absent, not empty. There is a difference:
# WRONG - passes owner="" which is not the same as not passing owner
- ansible.builtin.file:
path: /opt/app
state: directory
owner: "{{ file_owner | default('') }}"
# RIGHT - the argument disappears entirely when file_owner is undefined,
# so the module keeps the existing owner instead of trying to set ""
- ansible.builtin.file:
path: /opt/app
state: directory
owner: "{{ file_owner | default(omit) }}"
mode: "{{ file_mode | default(omit) }}"This appears in almost every well-written Galaxy role and almost never in tutorials, so recognising it reads as real-world exposure.
Environment variables
Two different directions, frequently confused:
# 1. Read an env var from the CONTROL NODE at template time
- ansible.builtin.debug:
msg: "Running as {{ lookup('env', 'USER') }} from {{ lookup('env', 'HOME') }}"
# 2. SET env vars for a task on the MANAGED NODE while it runs
- name: Run a build with a proxy configured
ansible.builtin.command: /opt/build.sh
environment:
HTTP_PROXY: http://proxy.internal:3128
HTTPS_PROXY: http://proxy.internal:3128
PATH: "/opt/toolchain/bin:{{ ansible_env.PATH }}"
# 3. Read the MANAGED NODE's environment - this is a gathered fact
- ansible.builtin.debug:
msg: "Remote PATH is {{ ansible_env.PATH }}"| Mechanism | Which machine, and when |
|---|---|
| lookup('env', 'X') | Control node, at template-evaluation time. Like every lookup, it runs locally |
| environment: | Managed node, for the duration of that task only. Can be set at task, block or play level |
| ansible_env.X | Managed node, as it was at fact-gathering time. Requires gather_facts |
This is the root cause of "the command works when I SSH in but fails through Ansible", and it is a good answer to have ready.
lookup — pulling data in from the control node
"{{ lookup('file', '/etc/hostname') }}" # read a local file
"{{ lookup('env', 'HOME') }}" # environment variable
"{{ lookup('pipe', 'git rev-parse --short HEAD') }}" # run a local command
"{{ lookup('password', '/dev/null length=20') }}" # generate a password
"{{ lookup('template', 'config.j2') }}" # render a template inline
"{{ lookup('first_found', ['a.yml', 'b.yml']) }}" # first path that existsThat distinction is asked surprisingly often, because getting it wrong produces a value that looks plausible and is from the wrong machine entirely.
🧪 Exercise D4.1 — Prove omit is different from an empty string
---
- name: omit vs empty string
hosts: web01
become: true
tasks:
- name: Create a file with a known owner
ansible.builtin.copy:
content: "test\n"
dest: /tmp/omit-test.txt
owner: deploy
mode: "0644"
- name: Update it, passing an EMPTY owner
ansible.builtin.copy:
content: "changed\n"
dest: /tmp/omit-test.txt
owner: "{{ undefined_owner | default('') }}"
mode: "0644"
ignore_errors: true
- name: Update it, using omit instead
ansible.builtin.copy:
content: "changed again\n"
dest: /tmp/omit-test.txt
owner: "{{ undefined_owner | default(omit) }}"
mode: "0644"✅ Expected result — click to reveal
TASK [Create a file with a known owner] **************** changed: [web01]
TASK [Update it, passing an EMPTY owner] ***************
fatal: [web01]: FAILED! => {"msg": "chown failed: failed to look up user ''"}
...ignoring
TASK [Update it, using omit instead] ******************* changed: [web01]An empty string is a value. The module dutifully tried to chown the file to a user literally named "" and failed. omit removed the argument entirely, so copy simply left the existing ownership alone.
Why this matters for roles: a role that exposes app_file_owner as an optional setting must use default(omit), otherwise every consumer who does not set it gets a hard failure. Using default('') there is a genuine bug, and it is one that only shows up for the users who did not configure the option — the people least likely to know why.
🎯 Interview questions — omit, environment, lookup
Q. What is omit and when do you use it?
A special value that tells Ansible to not pass that parameter at all, as distinct from passing an empty one.
Use it for genuinely optional module arguments — owner: "{{ file_owner | default(omit) }}". Without it, an unset variable becomes an empty string and the module tries to act on it, typically failing.
It matters most when writing reusable roles, where you cannot know which options the consumer will supply.
Q. Where does a lookup execute — control node or managed node?
Always the control node. lookup('file', '/etc/hostname') reads the control node's file, lookup('pipe', 'hostname') runs the command locally.
To read a file on the target you need the slurp module, or command: cat with register.
Getting this wrong is insidious because you get a real, plausible-looking value — from entirely the wrong machine.
Q. A command works when you SSH in manually but fails through Ansible. Why?
The environment differs. Ansible runs a non-interactive SSH session, so profile scripts that are guarded to interactive shells — much of .bashrc, and often .bash_profile — are never sourced. PATH in particular is frequently shorter.
Fixes: use an absolute path to the binary; set what you need explicitly with environment:; or inspect ansible_env.PATH to see what the task actually has.
D5 · Debugging variables
debug shows you the amount; type_debug is tasting it. That is why so many "my playbook is behaving strangely" problems turn out to be a number quietly stored as text, or a yes/no stored as the word "false" — which, being a non-empty string, is true.
- ansible.builtin.debug:
var: myvar # print one variable
- ansible.builtin.debug:
var: hostvars[inventory_hostname] # print EVERY variable for this host
- ansible.builtin.debug:
msg: "{{ myvar | type_debug }}" # what TYPE is it? string? bool? list?
- ansible.builtin.debug:
var: ansible_facts # every gathered fact
- ansible.builtin.assert: # fail early with a clear message
that:
- app_version is defined
- app_port | int > 1024
fail_msg: "app_version must be set and app_port must be above 1024"ansible-inventory --host web01 # inventory-derived vars, no connection
ansible-playbook site.yml -vvv # the resolved arguments each module received
ansible localhost -m ansible.builtin.debug -a "msg={{ 'test' | upper }}" # try a filter quickly🧪 Exercise D4.1 — Catch the string-vs-boolean trap
---
- name: type_debug in action
hosts: localhost
gather_facts: false
vars:
real_bool: false
string_bool: "false"
tasks:
- ansible.builtin.debug:
msg:
- "real_bool is {{ real_bool | type_debug }}"
- "string_bool is {{ string_bool | type_debug }}"
- name: This is correctly SKIPPED
ansible.builtin.debug:
msg: "real_bool was truthy"
when: real_bool
- name: This RUNS - and it should not
ansible.builtin.debug:
msg: "string_bool was truthy!"
when: string_bool✅ Expected result — click to reveal
ok: [localhost] => {
"msg": [
"real_bool is bool",
"string_bool is str"
]
}
TASK [This is correctly SKIPPED] ***********************************
skipping: [localhost]
TASK [This RUNS - and it should not] *******************************
ok: [localhost] => {
"msg": "string_bool was truthy!"
}"false" is a non-empty string, and every non-empty string is truthy. The task ran despite the variable saying false in plain sight.
Where this actually bites you: -e passes everything as a string. So ansible-playbook site.yml -e enable_debug=false sets enable_debug to the string "false", and every when: enable_debug guard in your playbook fires.
Three defences:
when: myvar | bool # cast it explicitlyansible-playbook site.yml -e '{"enable_debug": false}' # JSON syntax preserves the real type- ansible.builtin.assert:
that: myvar is boolean
fail_msg: "myvar must be a real boolean, not a string"| bool is the everyday answer, and knowing why it is needed — that -e stringifies everything — is the part interviewers are actually probing.
🎯 Interview questions — Debugging
Q. How do you debug a variable that has an unexpected value?
debug: var=myvar for the value, debug: var=hostvars[inventory_hostname] to dump every variable the host has, and {{ myvar | type_debug }} to check its type — which is the actual cause more often than people expect.
ansible-inventory --host <name> for the inventory-derived layer without connecting, and -vvv to see the resolved arguments a module actually received.
And assert to fail early with a clear message rather than letting a bad value propagate into a config file.
Q. when: myvar behaves as true even though the value is false. Why?
Because myvar is the string "false", not the boolean false — and any non-empty string is truthy.
The usual source is -e, which passes everything as a string: -e enable_debug=false yields "false".
Fixes: when: myvar | bool to cast explicitly, or pass JSON on the command line — -e '{"enable_debug": false}' — which preserves the real type. type_debug is how you confirm the diagnosis in one line.
Part E · Putting it together
E1 · Production practice
| Habit | Why |
|---|---|
| Everything user-facing goes in a role's defaults/, never vars/ | vars/ is level 15 and silently beats inventory and group_vars — hours of confused debugging |
| Variables live in group_vars/ and host_vars/, not inline in the inventory | Reviewable in a diff, one concern per file, and Vault-encryptable per file |
| Prefix role variables with the role name — nginx_port, not port | Variables are global once a role is included; port from two roles will collide |
| Use ansible_facts['distribution'] rather than ansible_distribution | Namespaced, collision-proof, and survives inject_facts_as_vars = false |
| Bracket notation for any externally sourced data | Dot notation silently returns Python method objects for keys like keys, items, count |
| | bool on anything that might arrive from -e | -e stringifies everything, and "false" is truthy |
| combine(..., recursive=True) rather than hash_behaviour = merge | Explicit at the point of use; the global setting is deprecated and breaks imported roles |
| assert early on critical variables | Fails with a clear message instead of writing a broken config file |
| gather_facts: false or gather_subset in plays that use no facts | A full extra module execution per host, on every run, for data you ignore |
| Quote anything boolean-ish or version-ish — "NO", "1.10", "0644" | The Norway problem, float truncation, and octal modes |
E2 · Capstone exercise
Brief. Write a playbook that, for the web group:
- Reads a db_host value from db01's gathered facts, not from hard-coded inventory
- Merges a base config dictionary with a per-environment override, preserving nested keys
- Falls back to 1.0.0 if app_version is not supplied, but fails loudly if env is not supplied
- Writes a config file whose content proves all of the above
- Correctly handles -e enable_tls=false being passed as a string
- Runs twice with changed=0 on the second run
✅ Model answer — attempt it first, then click
---
# Requirement 1: db01's facts must exist before web can read them
- name: Gather facts from the database tier
hosts: db
gather_facts: true
tasks: []
- name: Capstone - variables, facts and precedence
hosts: web
gather_facts: true
vars:
base_config:
host: 0.0.0.0
port: 8080
tls:
enabled: true
cert: /etc/ssl/default.pem
env_overrides:
production:
port: 443
tls:
cert: /etc/ssl/prod.pem
staging:
port: 8443
tasks:
- name: Requirement 3 - fail fast on a missing env
ansible.builtin.assert:
that:
- env is defined
- env in env_overrides
fail_msg: "env must be set to one of: {{ env_overrides.keys() | list }}"
- name: Requirement 2 - merge, preserving nested keys
ansible.builtin.set_fact:
final_config: "{{ base_config | combine(env_overrides[env], recursive=True) }}"
app_version_resolved: "{{ app_version | default('1.0.0', true) }}"
tls_on: "{{ enable_tls | default(true) | bool }}" # requirement 5
- name: Requirement 1 + 4 - write the config
ansible.builtin.copy:
dest: /etc/myapp/app.conf
owner: root
group: root
mode: "0644"
content: |
# generated by ansible - do not edit
app_version={{ app_version_resolved }}
environment={{ env }}
listen_host={{ final_config.host }}
listen_port={{ final_config.port }}
tls_enabled={{ tls_on }}
tls_cert={{ final_config.tls.cert }}
db_host={{ hostvars['db01']['ansible_facts']['default_ipv4']['address'] }}
become: trueThe five things most people miss:
- The preliminary hosts: db play with tasks: []. Without it, hostvars['db01'] has no facts and requirement 1 fails. This is Exercise B4.2.
- recursive=True. Without it the nested tls dictionary is replaced wholesale and enabled: true silently disappears — TLS quietly turns off.
- default('1.0.0', true) with the boolean argument — so an empty app_version also falls back, not only an undefined one.
- | bool on enable_tls. -e enable_tls=false arrives as the string "false", which is truthy. Without the cast, requirement 5 fails and TLS stays on when you asked for it off.
- assert rather than default for env. There is no safe default for an environment name — defaulting it would deploy staging config to production. Fail loudly.
Verify all six:
ansible-playbook capstone.yml -e env=production
ansible-playbook capstone.yml -e env=production # changed=0
ansible-playbook capstone.yml # should FAIL on the assert
ansible-playbook capstone.yml -e env=production -e enable_tls=false
ssh web01 'cat /etc/myapp/app.conf'E3 · Official documentation
| Link | Covers |
|---|---|
| Using Variables | The whole of Parts A and C, including the authoritative 22-level precedence list |
| Discovering variables: facts and magic variables | Facts, facts.d custom facts, fact caching, gather_subset |
| Special (magic) variables | The complete list — hostvars, groups, group_names, ansible_play_hosts |
| Using filters to manipulate data | default, mandatory, combine, bool, type_debug and the rest |
| Organizing host and group variables | group_vars/ and host_vars/ layout, and group priority |
| ansible.builtin.set_fact | Including the cacheable option |
| ansible.builtin.setup | filter, gather_subset, and every fact category |
| ansible.builtin.assert | Validating variables before they cause damage |
| Discovering all Jinja2 filters | Data manipulation patterns beyond the basics |
E4 · Self-assessment
Answer each out loud before opening it.
1. Name the lowest and highest levels of variable precedence.
Lowest real variable source: role defaults (defaults/main.yml) — they exist to be overridden.
Highest: extra vars (-e) — they always win and cannot be overridden from inside a playbook, not even by a task-level vars:.
In between, more specific beats more general: host beats group, child group beats parent, task beats block beats play.
2. Why is a role's vars/main.yml dangerous for user-facing settings?
It sits at level 15, above inventory group_vars (6) and host_vars (9–10). A consumer of the role setting the value in group_vars sees it silently ignored, with no warning.
Anything a user might want to change belongs in defaults/ at level 2.
3. set_fact vs register vs vars: — scope and purpose.
register captures a module's complete result object. set_fact assigns a value you computed. Both are host-scoped and persist across later plays in the same run, both at level 19.
vars: is play-scoped and disappears when the play ends. That difference is how you pass data between plays.
4. A host is in two sibling groups defining the same variable. Who wins?
Higher ansible_group_priority wins (default 1). If tied, alphabetical by group name, last one winning — which means renaming a group can silently change behaviour.
Child groups always beat parents regardless of priority, and host vars always beat all group vars.
5. Why does hostvars['db01']['ansible_default_ipv4'] fail in a play targeting only web?
Because db01's facts were never gathered — the play never connected to it. groups knows db01 exists; that is not the same as having its facts.
Fix with a preliminary - hosts: db play with gather_facts: true and tasks: [], or fact caching, or store the value as an inventory host var so no gathering is needed.
6. What happens when the same dictionary is defined at two precedence levels?
The higher one replaces the whole dictionary — it does not merge. Keys only in the lower version are lost.
Fix with combine(), and recursive=True whenever there are nested dictionaries, otherwise nested levels are still replaced wholesale.
7. when: myvar fires even though the value is false. Explain.
myvar is the string "false", and non-empty strings are truthy. Usually caused by -e, which passes everything as a string.
Fix with | bool, or pass JSON: -e '{"myvar": false}'. Confirm the diagnosis with type_debug.
8. When is default('x', true) correct, and when is it wrong?
Correct for strings that must not be empty — it substitutes on any falsy value, including "".
Wrong for numbers and booleans, where it would replace a deliberate 0 or false with the fallback. For those, plain default('x') is right.
9. Difference between inventory_hostname and ansible_hostname?
inventory_hostname is the name you gave the host in the inventory — available with no fact gathering.
ansible_hostname is a gathered fact: the machine's actual configured hostname, which can differ entirely. In a cloud inventory keyed by IP they are almost always different.
10. Why prefer ansible_facts['distribution'] over ansible_distribution?
It is namespaced, so it cannot collide with your own variables, and it keeps working if inject_facts_as_vars is set to false.
The flat form exists only because Ansible injects facts as top-level variables, and that injection is a configurable behaviour rather than a guarantee.
11. Name three ways to reduce fact-gathering cost.
gather_facts: false for plays that reference no ansible_* variable. gather_subset to collect only the categories needed, e.g. "!all,!min,network". And fact caching via jsonfile or redis so facts persist across runs.
The framing that lands: on a 500-host fleet this is minutes on every CI-triggered run, for data the play never reads.
E5 · Command reference — everything from this module
Inspecting variables
ansible-inventory --host web01 # ⭐ everything the inventory resolves for a host
ansible-inventory --graph --vars # the tree with variables attached
ansible web01 -m ansible.builtin.debug -a "var=myvar" # ⭐ one variable, ad-hoc
ansible web01 -m ansible.builtin.debug -a "var=hostvars[inventory_hostname]" # ⭐ ALL of them
ansible-playbook site.yml -vvv # ⭐ the arguments each module actually receivedInspecting facts
ansible web01 -m ansible.builtin.setup # every fact
ansible web01 -m ansible.builtin.setup -a 'filter=ansible_distribution*' # ⭐ filtered
ansible web01 -m ansible.builtin.setup -a 'filter=ansible_mem*'
ansible web01 -m ansible.builtin.setup -a 'filter=ansible_default_ipv4'
ansible web01 -m ansible.builtin.setup -a 'filter=ansible_local' # ⭐ custom facts.d
ansible web01 -m ansible.builtin.setup -a 'gather_subset=!all,!min,network'
ansible web01 -m ansible.builtin.setup | wc -l # how much is being gatheredPassing variables in
ansible-playbook site.yml -e app_version=2.4.1 # ⭐ always wins
ansible-playbook site.yml -e '{"debug": false, "port": 8080}' # ⭐ JSON preserves real types
ansible-playbook site.yml -e "@vars/production.yml" # ⭐ load a whole file
ansible-playbook site.yml -e env=prod -e app_version=2.4.1 # repeatableTesting filters and expressions quickly
ansible localhost -m ansible.builtin.debug -a "msg={{ 'test' | upper }}"
ansible localhost -m ansible.builtin.debug -a "msg={{ myvar | type_debug }}" # ⭐ string or bool?
ansible localhost -m ansible.builtin.debug -a "msg={{ [1,2,3] | length }}"
ansible-doc -t filter -l # ⭐ every filter available
ansible-doc -t filter ansible.builtin.combine # docs for one filter
ansible-doc -t lookup -l # every lookup pluginFact caching
# ansible.cfg
[defaults]
fact_caching = jsonfile
fact_caching_connection = /tmp/ansible_facts
fact_caching_timeout = 7200ls /tmp/ansible_facts/ # what is cached
cat /tmp/ansible_facts/web01 | python3 -m json.tool | head -30
rm -rf /tmp/ansible_facts/ # ⭐ clear a stale cache
ansible-playbook site.yml --flush-cache # ⭐ force a re-gather for this runThe debugging snippets worth memorising
- ansible.builtin.debug: var=myvar # ⭐ the value
- ansible.builtin.debug: var=hostvars[inventory_hostname] # ⭐ everything this host has
- ansible.builtin.debug: msg="{{ myvar | type_debug }}" # ⭐ the TYPE - solves most surprises
- ansible.builtin.debug: var=ansible_facts # all gathered facts
- ansible.builtin.debug: var=groups # the whole inventory structure
- ansible.builtin.debug: var=group_names # groups THIS host is in
- ansible.builtin.assert: # ⭐ fail early, fail clearly
that:
- app_version is defined
- app_port | int > 1024
fail_msg: "app_version must be set and app_port must be above 1024"ansible-inventory --host web01 # 1. what does the inventory layer say?- ansible.builtin.debug: var=hostvars[inventory_hostname] # 2. what does the host actually have at runtime?
- ansible.builtin.debug: msg="{{ myvar | type_debug }}" # 3. is it even the type I think it is?Step 3 resolves more cases than people expect — a string "false" where a boolean was intended is the single most common cause.
Conditionals, loops, block/rescue/always, tags, and error handling. You have already used when: and register here — Module 03 covers the full control-flow toolkit built on top of them.
📚 Sources for the interview questions
The precedence ladder is taken directly from the official Ansible documentation and verified against the current release.
Question selection cross-referenced against publicly published 2026 Ansible interview question sets:
- Spacelift — 50+ Top Ansible Interview Questions & Answers for 2026
- GeeksforGeeks — Top 50+ Ansible Interview Questions and Answers
- Vinsys — Top 30 Ansible Interview Questions and Answers 2026
- K21 Academy — Ansible Interview Questions & Answers 2026
- Hirist — Top 25+ Ansible Interview Questions and Answers 2026
Answers were rewritten and deepened rather than reproduced — published versions are usually correct but shallow, and the added operational detail is what differentiates a candidate in the room.