Skip to content

6. Variables and precedence

This chapter answers one question — which value wins? — and it generates more support questions than the rest of Ansible combined.

Twenty-two levels, lowest to highest. Do not memorise it. Read it once, then learn the five rules underneath.

1 command-line values (-u, -b — not -e)
2 role defaults ← defaults/main.yml
3 inventory file group vars
4 inventory group_vars/all
5 playbook group_vars/all
6 inventory group_vars/*
7 playbook group_vars/*
8 inventory file 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 ← vars/main.yml
16 block vars
17 task vars
18 include_vars
19 set_fact / registered vars
20 role (and include_role) params
21 include params
22 extra vars (-e) ← ALWAYS WINS

1. -e always wins. Nothing overrides an extra var. That makes it perfect for a one-off override on the command line and dangerous as a habit — a variable set with -e in a CI job cannot be changed by anything in the repository, which is confusing to everyone reading the repository.

2. Role defaults are the weakest thing there is; role vars are near the strongest. They are levels 2 and 15, at opposite ends, and this is deliberate:

roles/nginx/defaults/main.yml
→ "here is a sensible value; PLEASE override me"
→ this is where a role's interface lives
roles/nginx/vars/main.yml
→ "this is internal to the role; do NOT override"
→ constants, lookup tables, per-OS package names

Every value a user of your role might want to change belongs in defaults/. Putting it in vars/ means a caller’s group_vars cannot override it, which produces a very confusing afternoon for whoever tries.

3. More specific beats more general. host_vars beats group_vars. A child group beats its parent. all is the weakest group.

4. Later beats earlier, in the same category. Two groups at the same depth are merged in alphabetical order, so zebra beats alpha. This is arbitrary and worth avoiding: if two sibling groups set the same variable, use ansible_group_priority or restructure, rather than relying on the name.

5. set_fact beats almost everything below it. Level 19. A fact set at runtime overrides inventory, group vars, host vars and play vars — which is powerful and is why an accidental set_fact can make a variable stubbornly refuse to change from anywhere else.

The list is long; the practice is short.

group_vars/all.yml things true everywhere
group_vars/production.yml per-environment
group_vars/webservers.yml per-role-in-the-estate
host_vars/web1.yml the one machine that differs
roles/x/defaults/main.yml the role's public interface
roles/x/vars/main.yml the role's private constants
-e one-off overrides only

If you find yourself using more than these, the design is fighting you. Play vars:, vars_files:, task vars: and include_vars all exist and all have uses, and each one you add is another place someone must look to answer “where does this value come from”.

And when you cannot answer that question:

Terminal window
ansible -m ansible.builtin.debug -a "var=my_variable" web1
ansible-inventory --host web1 # everything, merged
ansible-playbook site.yml -vvv # shows variable resolution

Before the first task, Ansible runs the setup module on every host and collects several hundred facts about it.

- ansible.builtin.debug:
var: ansible_facts
ansible_facts['distribution'] 'Ubuntu'
ansible_facts['distribution_version'] '22.04'
ansible_facts['os_family'] 'Debian'
ansible_facts['processor_vcpus'] 8
ansible_facts['memtotal_mb'] 16008
ansible_facts['default_ipv4']['address'] '10.0.4.12'
ansible_facts['mounts'] [...]
ansible_facts['hostname'] / ['fqdn']
ansible_facts['service_mgr'] 'systemd'
ansible_facts['python_version']

Use ansible_facts['x'] rather than ansible_x. The old flat form (ansible_distribution) is fact injection into the top-level namespace, which can be turned off with inject_facts_as_vars = false and which risks colliding with your own variables. The dictionary form always works.

Gathering facts costs a round trip and a second or two per host. Two ways to reduce it:

- hosts: all
gather_facts: false # if you need none of them
[defaults]
gathering = smart # gather only if not cached
fact_caching = jsonfile
fact_caching_connection = /tmp/ansible_facts
fact_caching_timeout = 7200

Fact caching is the biggest single win on a large fleet, and it has a sharp edge: a cached fact is a fact from the past. A machine that gained memory, changed IP, or was upgraded reports the old value until the cache expires. Use a short timeout, and --flush-cache after anything that changes a machine’s shape.

You can also gather a subset:

- hosts: all
gather_subset: ['!all', '!min', 'network']

A machine can supply its own facts by dropping files in /etc/ansible/facts.d/:

/etc/ansible/facts.d/app.fact
[deployment]
version=2.4.0
tier=blue
- ansible.builtin.debug:
var: ansible_facts['ansible_local']['app']['deployment']['version']

Executable files there are run and must print JSON. This is how a machine tells Ansible something Ansible could not otherwise know, and it is genuinely useful for deployment state — though be careful not to build a second, undiscoverable configuration system inside the first one.

Not facts, but supplied by Ansible about the run itself:

inventory_hostname this host's name IN THE INVENTORY
inventory_hostname_short
ansible_play_hosts hosts still active in this play
ansible_play_batch hosts in the current `serial` batch
groups dict: group name → list of hosts
group_names this host's groups
hostvars EVERY host's variables
play_hosts (older name for play_hosts)
ansible_check_mode true under --check
ansible_version
omit a special "leave this out" value

Two of these do real work.

hostvars lets one host read another’s variables, which is how cross-machine configuration is written:

{% for host in groups['databases'] %}
server {{ hostvars[host]['ansible_facts']['default_ipv4']['address'] }}:5432
{% endfor %}

That is a load balancer config built from the inventory. Note the catch: hostvars only contains facts for hosts that have been gathered in this run. Building a config from groups['databases'] in a play limited to web servers gives you empty values unless the databases were touched or their facts were cached.

omit removes an argument entirely, which is the clean way to have an optional parameter:

- ansible.builtin.user:
name: "{{ item.name }}"
uid: "{{ item.uid | default(omit) }}" # not passed at all
groups: "{{ item.groups | default(omit) }}"
loop: "{{ users }}"

Without omit, default('') passes an empty string, which is not the same as not passing the argument — and for uid it is an error.

- ansible.builtin.set_fact:
app_version: "2.4.0"
cacheable: false # true would write it to the
# fact cache, persisting it

Three scoping facts that catch people:

set_fact is per-host. Setting it in a task sets it on the host that task ran on, not on all of them. To compute something once and use it everywhere, set it on one host and read it through hostvars, or use run_once with delegate_facts.

Variables set inside a loop do not accumulate. Each iteration overwrites. Building a list means combining with itself:

- ansible.builtin.set_fact:
found_paths: "{{ found_paths | default([]) + [item.path] }}"
loop: "{{ results.files }}"

cacheable: true writes to the fact cache, so the value survives into future playbook runs. That is occasionally what you want and usually a surprise.

- ansible.builtin.command: /opt/app/bin/status
register: app_status
changed_when: false

register is at level 19, so it beats nearly everything. Two behaviours to know:

A skipped task still registers. The variable exists, with skipped: true and no stdout. Code that reads app_status.stdout after a task that might skip must guard for it:

when: app_status is not skipped and 'healthy' in app_status.stdout

Registered variables persist across plays for the same host, so a variable registered in play 1 is still there in play 3. That is occasionally convenient and is a good way to read a stale value by accident.

Two rules, both from experience:

Prefix role variables with the role name. nginx_worker_processes, not worker_processes. Ansible has one flat namespace per host, so every role’s variables share it, and two roles that both define port will collide silently.

Never start a variable name with ansible_. That prefix is reserved, and shadowing a real one — ansible_user, ansible_host, ansible_port — changes how Ansible connects rather than doing what you meant.

Valid names are letters, digits and underscores, starting with a letter or underscore. Hyphens and dots are not valid and produce errors that do not obviously say so.

Everything from a template renders as a string unless you are careful.

vars:
enable_ssl: "false" # ← a STRING, and non-empty
# strings are TRUE
tasks:
- ansible.builtin.debug: msg="enabled"
when: enable_ssl # ← runs. Always.
# correct
enable_ssl: false # a real boolean
# or force it
when: enable_ssl | bool

| bool on anything that came from a variable file, an environment variable, or -e — because -e enable_ssl=false passes the string "false", which is true. This is the most common single-line bug in Ansible, and chapter 7 has more on why.

Similarly | int for numbers used in comparisons, and | length rather than truthiness for lists you expect to be empty.

  • Twenty-two levels, five rules: -e always wins; role defaults are weakest and role vars near-strongest; more specific beats more general; later beats earlier alphabetically among siblings; set_fact beats almost everything.
  • Everything a role’s user might change belongs in defaults/. Putting it in vars/ makes it unoverridable.
  • In practice use only group_vars/, host_vars/, role defaults, role vars, and -e for one-offs. Every other source is another place to look.
  • Use ansible_facts['x'], not the injected flat ansible_x.
  • Fact caching is the biggest win on a large fleet, and a cached fact is a fact from the past — short timeouts, and --flush-cache after changing a machine.
  • hostvars builds cross-machine configuration, and only holds facts for hosts gathered in this run.
  • | default(omit) removes an argument entirely — not the same as an empty string.
  • set_fact is per-host, does not accumulate in a loop, and cacheable: true persists it into future runs.
  • A skipped task still registers, so guard with is not skipped.
  • Prefix role variables with the role name, and never start a name with ansible_.
  • -e x=false passes the string "false", which is true. Use | bool.

Next: Jinja2 — the templating language that is doing far more work than you think, including inside every when: you have ever written.

Written by Sepuri Sai Krishna

© 2026CC BY 4.0Source