Module 02 — Variables, Facts & Precedence

Updated 20 August 2026

Module 02 · Variables, Facts & Precedence

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

The analogy. Think of a labelled jar in your kitchen. The jar says sugar on the front, and when you write the recipe you deliberately do not care what is inside it — you write "add the contents of the sugar jar" and move on. Someone else can fill that jar with white sugar or brown, and your recipe still works without a single edit.

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:

yaml
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

RuleDetail
Letters, digits, underscores onlyapp_port ✅ · app-port ❌ · app.port ❌ · app port
Must not start with a digit2nd_server ❌ · server_2
Must not collide with a Python keywordasync, lambda, import are all invalid
Must not collide with a playbook keywordenvironment, name, hosts — technically allowed but they shadow real keywords and cause baffling bugs
Hyphens are the trap. app-port looks perfectly reasonable and is invalid — Jinja2 reads it as app minus port. You will not always get a clear error; sometimes you get a silent wrong value. Underscores, always.

The quoting rule you cannot skip

yaml
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
The rule in one line: if a value starts with {{, quote the whole thing. And never put {{ }} inside when:, because when: is evaluated as a Jinja2 expression already — the braces are redundant and can break on complex expressions.
🧪 Exercise A1.1 — Break the quoting rule on purpose
yaml
---
- 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
plain text
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 hyphensapp-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 analogy. Think of three different containers in the same kitchen. The jar from A1 holds one thing. A shopping list holds many things in a definite order, which is what makes "the third item" a sensible phrase. An address book holds pairs — a name and the number filed under it — where order is irrelevant, because you never ask for "the third contact", you ask for the one filed under a name.

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.

yaml
# 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: 2002

Accessing them

yaml
"{{ 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
Dot vs bracket — and why bracket is safer. They usually behave identically, but dot notation breaks in two cases:
  1. Keys with special characters or spacesmyvar.some-key fails, myvar['some-key'] works
  2. Keys that collide with Python dictionary methodsmyvar.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

yaml
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)
The "Norway problem" is a genuine and famous YAML bug: an unquoted NO country code parses as the boolean false. Same for ON, OFF, Y, N, TRUE. Quote any string that could be mistaken for a boolean or a number — country codes, version strings, and anything octal-looking such as file modes.
🧪 Exercise A2.1 — Make the dot-notation trap happen
yaml
---
- 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
plain text
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

The analogy. Think of being told what time to arrive at a wedding. The printed invitation says 7pm. Your mother says 6:30. Your cousin in the car says 6:15. Then the groom texts you directly: 6pm. Four sources, one question, and no way to satisfy all of them — so you need a settled rule about who wins. And the rule people actually use is not "the loudest" or "the most recent": it is roughly the closer the source is to the event, the more it counts.

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:2px
The two anchors to memorise first, because they bound everything else:

Role 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.

Also note: variables are resolved per host. Two hosts in the same play routinely have different values for the same variable name, because each host's own inventory vars, facts and group memberships feed its own resolution. This is obvious once stated and consistently forgotten in practice.

Part B · Facts

B1 · What facts are

The analogy. Think of the start of a doctor's appointment. Before you have said a word about why you came, they have taken your height, your weight, your blood pressure and your temperature. You did not tell them any of it and you could not have argued about it — they measured it.

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

FactExample value
ansible_distributionUbuntu
ansible_distribution_major_version22
ansible_os_familyDebian — groups Ubuntu/Debian, or RedHat/CentOS/Rocky. Use this, not ansible_distribution, for cross-distro conditionals
ansible_default_ipv4.address10.0.1.15
ansible_hostname / ansible_fqdnweb01 / web01.example.com
ansible_processor_vcpus4
ansible_memtotal_mb7936
ansible_mountsa list of dicts — device, mount point, size, free space
ansible_service_mgrsystemd
ansible_python_version3.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:

yaml
"{{ ansible_facts['distribution'] }}"    # the modern, recommended form
"{{ ansible_distribution }}"             # the legacy "injected" form

The 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.

Prefer ansible_facts['distribution']. It is namespaced, it cannot collide with your own variables, and it keeps working if someone disables fact injection. Most existing playbooks use the flat form, so you must be able to read both — but write the modern one.
🧪 Exercise B1.1 — See the full scale of what is gathered
bash
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
plain text
1043
plain text
web01 | 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
}
plain text
"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
plain text
"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
bash
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
yaml
# 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
plain text
facts-on.yml   real  0m6.412s
facts-off.yml  real  0m1.883s

Roughly 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:

yaml
gather_facts: false                     # the play uses no ansible_* variable
gather_subset: "!all,!min,network"      # gather only what you need
plain text
# ansible.cfg — gather once, reuse across runs
[defaults]
fact_caching = jsonfile
fact_caching_connection = /tmp/ansible_facts
fact_caching_timeout = 7200

The 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

The analogy. Think of the allergy note on your medical file. The doctor from B1 can measure your height, your weight and your blood pressure. No amount of examining will ever tell them you are allergic to penicillin — you have to say it once, and then it sits on the file for every future visit.

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

plain text
# /etc/ansible/facts.d/deployment.fact   (INI format)
[app]
version=2.4.1
tier=production
owner=platform-team
yaml
# Accessed as:
"{{ ansible_local.deployment.app.version }}"    # -> 2.4.1
#     ^          ^          ^   ^
#     |          |          |   +-- the key
#     |          |          +------ the INI section
#     |          +----------------- the filename without .fact
#     +---------------------------- always ansible_local

Dynamic custom facts — any executable that prints JSON

bash
#!/bin/bash
# /etc/ansible/facts.d/hardware.fact   -- must be executable (0755)
echo "{\"disk_count\": $(lsblk -d -n | wc -l), \"kernel\": \"$(uname -r)\"}"
yaml
"{{ ansible_local.hardware.disk_count }}"
Where this genuinely earns its keep: a host declaring things Ansible cannot infer — which application tier it belongs to, which customer it serves, its patch window, its compliance class. The host becomes self-describing, and your playbooks target ansible_local.deployment.app.tier instead of hard-coding hostnames into inventory groups.

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
yaml
---
- 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
plain text
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:

bash
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

The analogy. Think of three pieces of paper you might be carrying around town. The shopping note you wrote before leaving the house. The receipt a shop handed you, which you did not write and which carries far more detail than you asked for — the time, the till number, the payment method. And a sticky note you scribble yourself halfway through the day and push into your pocket.

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:2px

register — capture what a task returned

yaml
- 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.exists

A registered variable is the module's entire result object, not just a value. Common keys:

KeyContains
.changeddid the task change anything
.faileddid it fail
.skippedwas it skipped by a when:
.rcexit code — command/shell only
.stdout / .stderroutput as one string
.stdout_linesthe same output already split into a list — usually what you actually want
.resultsa list of result objects, when the task used a loop

set_fact — compute and name a value

yaml
- 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"
The distinction that gets asked: register captures what a module returned. set_fact sets a value you computed. Both become host-scoped and both outlive the current play — they persist for that host across every remaining play in the run, which plain vars: do not.

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
yaml
---
- 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
plain text
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
yaml
---
- 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
plain text
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

The analogy. Think of a child sitting in a classroom. Nobody has to tell them their own name, which class they are in, or who else is in the room. They simply know it by being there, and it would be strange to have to write it on the board.

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.

VariableWhat it holds
inventory_hostnameThe current host's inventory name — web01, not necessarily its real hostname
inventory_hostname_shortEverything before the first dot
group_namesList of groups this host belongs to
groupsDict of every group in the inventory and its members
hostvarsDict giving access to any other host's variables and facts
ansible_play_hostsHosts still active in this play (failed ones are removed)
ansible_play_batchHosts in the current serial batch
ansible_playbook_pythonPython interpreter on the control node
playbook_dirAbsolute path of the playbook's directory — useful for building file paths
ansible_check_modeBoolean — 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.

yaml
- 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
yaml
# 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 hostvars gotcha: you can only read facts for a host whose facts have actually been gathered in this run. If your play targets only web and you reach into hostvars['db01']['ansible_default_ipv4'], it will be undefined — Ansible never talked to db01.

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
yaml
---
- 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
plain text
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:

  1. 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.
  2. 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.
  3. 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
yaml
---
# 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
plain text
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:

yaml
- 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'] }}"
plain text
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

In everyday terms

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.

plain text
 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

Source: Ansible documentation — variable precedence

Nobody expects you to recite all 22 in an interview. What they are testing is whether you know the shape:

🔻 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

roles/x/defaults/main.yml — level 2

Sensible defaults meant to be overridden by whoever uses the role.

Put almost everything here.

roles/x/vars/main.yml — level 15

Internal constants the user should not change. Beats inventory, beats group_vars, beats host_vars.

Use sparingly.

The failure this causes in real teams: someone puts nginx_port in a role's vars/main.yml, then a colleague sets nginx_port: 8080 in group_vars/web.yml and it is silently ignored — because level 15 beats level 6. Hours are lost. The rule: if a user might ever want to change it, it belongs in defaults/.
🧪 Exercise C1.1 — Watch higher precedence win, level by level
bash
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
yaml
# 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_vars

Run it three times:

bash
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:

plain text
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:

plain text
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

The analogy. Think of how you file paperwork at home. You do not throw everything into one drawer — there is a folder per person and a folder per topic: Ahmed, Car, House. Finding anything later depends entirely on the label being the one you will actually look under.

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.

plain text
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/
The naming is not a convention — it is a lookup. The filename must exactly match the group or host name from the inventory. group_vars/webservers.yml applies to a group literally called webservers. Name the group web and the file webservers.yml and it is silently ignored, with no warning whatsoever. That silence is what makes this a genuinely common bug.
A directory instead of a filegroup_vars/prod/ containing several .yml files — has all of them loaded and merged. This is how teams keep plain configuration separate from Vault-encrypted secrets while both apply to the same group.

Two locations, two priorities

There are two valid places for these directories, and they are not equivalent:

  • Beside the inventoryinventory/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

plain text
[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
1Child groups beat parent groups — always, regardless of priority
2At the same level, higher ansible_group_priority wins (default 1)
3If priorities tie, resolution is alphabetical by group name — and the last one alphabetically wins
4Host vars always beat all group vars, whatever their priority
Rule 3 is a genuine production hazard. Two sibling groups both defining app_version, both at default priority, and the winner is decided by alphabetical order of the group name. Rename a group from zone-a to alpha-zone for tidiness and you silently change which value applies.

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
plain text
# inventory/hosts.ini
[alpha]
web01

[zulu]
web01

[alpha:vars]
winner=alpha_won

[zulu:vars]
winner=zulu_won
bash
ansible -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:

plain text
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]:

plain text
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
bash
ansible-inventory -i inventory/ --host web01
ansible-inventory -i inventory/ --list
ansible-inventory -i inventory/ --graph --vars
Expected result — click to reveal
json
{
    "ansible_host": "10.0.1.15",
    "ansible_user": "deploy",
    "deploy_batch": "first",
    "http_port": 80,
    "winner": "alpha_won"
}
plain text
@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 analogy. Think of a recipe that says "add the spice from jar 3", and you open the cupboard to find jar 3 is not there. You have three sensible responses: reach for a substitute, stop cooking and say clearly what is missing, or decide that step is optional and skip it.

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:

plain text
fatal: [web01]: FAILED! => {"msg": "The task includes an option with an
undefined variable. The error was: 'app_version' is undefined"}
yaml
# 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 > 0
default('x') vs default('x', true) — the difference bites people.

Plain 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
yaml
---
- 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
plain text
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:

yaml
# group_vars/all.yml
app_config:
  host: 0.0.0.0
  port: 8080
  debug: false

# host_vars/web01.yml
app_config:
  port: 9090
yaml
# The result for web01 is NOT what most people expect:
{"port": 9090}          # host and debug are GONE - the whole dict was replaced
This surprises everybody once. Higher precedence replaces the entire dictionary rather than merging keys into it. Your carefully written defaults vanish because someone overrode one key.

Two ways to fix it

yaml
# 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) }}"
plain text
# FIX B (discouraged) - change the global behaviour in ansible.cfg
[defaults]
hash_behaviour = merge
Why hash_behaviour = merge is discouraged: it changes semantics globally for every dictionary in the project, it is not the default anyone else expects, and any role you import from Galaxy was written and tested assuming replace. Your playbook then behaves differently from the same playbook on a colleague's machine. It is officially deprecated in favour of combine.

Prefer 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
yaml
---
- 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:

json
{
    "debug": false,
    "host": "0.0.0.0",
    "port": 9090,
    "tls": {
        "cert": "/etc/ssl/custom.pem"
    }
}

Recursive merge:

json
{
    "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

yaml
---
- 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 }}"
vars_prompt blocks automation. A playbook that prompts cannot run in CI, cannot run from a cron job, and cannot run from AWX without a Survey. It is fine for a deliberately interactive runbook, and wrong for anything scheduled.

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

The analogy. Think of filling in a paper form that has a box for "middle name", and you do not have one. Leaving the box blank and writing the word "none" in it are not the same thing at all. Leave it blank and the clerk skips over it. Write "none" and somewhere in that system there is now a person whose middle name is None.

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:

yaml
# 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) }}"
omit is a special value Ansible replaces with "this parameter was never supplied." It is the correct way to write a task with genuinely optional arguments — which is exactly what you need when writing a reusable role, because the role cannot know which options its user cares about.

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:

yaml
# 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 }}"
MechanismWhich 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.XManaged node, as it was at fact-gathering time. Requires gather_facts
ansible_env is the remote user's login environment as captured during fact gathering. It is not affected by anything you set with environment:, and it reflects the environment of a non-interactive SSH session — which is often not the same as what you see when you SSH in and type env. Profile scripts that only run for interactive shells will not have been sourced.

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

yaml
"{{ 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 exists
Every lookup runs on the control node, never on the target. lookup('file', '/etc/hostname') reads the control node's /etc/hostname, not the managed node's. To read a file on the target you need the slurp module or command: cat.

That 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
yaml
---
- 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
plain text
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

The analogy. Think of a dish that has come out wrong. Before you start arguing about the recipe, you open the jars and look inside — and often the amounts were right and the contents were not. The jar labelled sugar, from A1, was actually full of salt. The two look identical on the shelf, and the only way to tell them apart is to taste.

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.

yaml
- 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"
bash
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
type_debug deserves its own mention. A large share of "my when: condition is behaving strangely" turns out to be a string "false" rather than a boolean false — and "false" is a non-empty string, so it is truthy. One type_debug answers the question immediately, where staring at the YAML does not.
🧪 Exercise D4.1 — Catch the string-vs-boolean trap
yaml
---
- 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
plain text
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:

yaml
when: myvar | bool               # cast it explicitly
bash
ansible-playbook site.yml -e '{"enable_debug": false}'    # JSON syntax preserves the real type
yaml
- 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

HabitWhy
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 inventoryReviewable in a diff, one concern per file, and Vault-encryptable per file
Prefix role variables with the role name — nginx_port, not portVariables are global once a role is included; port from two roles will collide
Use ansible_facts['distribution'] rather than ansible_distributionNamespaced, collision-proof, and survives inject_facts_as_vars = false
Bracket notation for any externally sourced dataDot 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 = mergeExplicit at the point of use; the global setting is deprecated and breaks imported roles
assert early on critical variablesFails with a clear message instead of writing a broken config file
gather_facts: false or gather_subset in plays that use no factsA 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

Attempt this without looking anything up. It exercises precedence, facts, hostvars, merging, defaults and type safety together.

Brief. Write a playbook that, for the web group:

  1. Reads a db_host value from db01's gathered facts, not from hard-coded inventory
  2. Merges a base config dictionary with a per-environment override, preserving nested keys
  3. Falls back to 1.0.0 if app_version is not supplied, but fails loudly if env is not supplied
  4. Writes a config file whose content proves all of the above
  5. Correctly handles -e enable_tls=false being passed as a string
  6. Runs twice with changed=0 on the second run
Model answer — attempt it first, then click
yaml
---
# 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: true

The five things most people miss:

  1. The preliminary hosts: db play with tasks: []. Without it, hostvars['db01'] has no facts and requirement 1 fails. This is Exercise B4.2.
  2. recursive=True. Without it the nested tls dictionary is replaced wholesale and enabled: true silently disappears — TLS quietly turns off.
  3. default('1.0.0', true) with the boolean argument — so an empty app_version also falls back, not only an undefined one.
  4. | 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.
  5. 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:

bash
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

LinkCovers
Using VariablesThe whole of Parts A and C, including the authoritative 22-level precedence list
Discovering variables: facts and magic variablesFacts, facts.d custom facts, fact caching, gather_subset
Special (magic) variablesThe complete list — hostvars, groups, group_names, ansible_play_hosts
Using filters to manipulate datadefault, mandatory, combine, bool, type_debug and the rest
Organizing host and group variablesgroup_vars/ and host_vars/ layout, and group priority
ansible.builtin.set_factIncluding the cacheable option
ansible.builtin.setupfilter, gather_subset, and every fact category
ansible.builtin.assertValidating variables before they cause damage
Discovering all Jinja2 filtersData manipulation patterns beyond the basics
The offline route, same as Module 01: ansible-doc -t filter -l lists every available filter, and ansible-doc -t filter ansible.builtin.combine gives you its documentation without opening a browser.

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

Commands and snippets introduced in Module 02. ⭐ marks genuinely daily-use.

Inspecting variables

bash
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 received

Inspecting facts

bash
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 gathered

Passing variables in

bash
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     # repeatable

Testing filters and expressions quickly

bash
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 plugin

Fact caching

plain text
# ansible.cfg
[defaults]
fact_caching = jsonfile
fact_caching_connection = /tmp/ansible_facts
fact_caching_timeout = 7200
bash
ls /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 run

The debugging snippets worth memorising

yaml
- 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"
The three-command diagnosis for any "wrong variable value" problem:
bash
ansible-inventory --host web01                 # 1. what does the inventory layer say?
yaml
- 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.


Next — Module 03 · Playbook Control Flow.

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:

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.

Spotted a mistake or want something added? Send me a note.