6. Variables and precedence
Ansible · 6. Variables and precedenceThis chapter answers one question — which value wins? — and it generates more support questions than the rest of Ansible combined.
The precedence list
Section titled “The precedence list”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 WINSThe five rules that replace the list
Section titled “The five rules that replace the list”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 namesEvery 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.
Where to actually put things
Section titled “Where to actually put things”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 onlyIf 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:
ansible -m ansible.builtin.debug -a "var=my_variable" web1ansible-inventory --host web1 # everything, mergedansible-playbook site.yml -vvv # shows variable resolutionBefore 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 cachedfact_caching = jsonfilefact_caching_connection = /tmp/ansible_factsfact_caching_timeout = 7200Fact 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']Custom facts
Section titled “Custom facts”A machine can supply its own facts by dropping files in /etc/ansible/facts.d/:
[deployment]version=2.4.0tier=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.
Magic variables
Section titled “Magic variables”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" valueTwo 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.
set_fact and scope
Section titled “set_fact and scope”- ansible.builtin.set_fact: app_version: "2.4.0" cacheable: false # true would write it to the # fact cache, persisting itThree 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.
Registered variables
Section titled “Registered variables”- ansible.builtin.command: /opt/app/bin/status register: app_status changed_when: falseregister 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.stdoutRegistered 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.
Variable naming
Section titled “Variable naming”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.
The type trap
Section titled “The type trap”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.
What to take from this chapter
Section titled “What to take from this chapter”- Twenty-two levels, five rules:
-ealways wins; roledefaultsare weakest and rolevarsnear-strongest; more specific beats more general; later beats earlier alphabetically among siblings;set_factbeats almost everything. - Everything a role’s user might change belongs in
defaults/. Putting it invars/makes it unoverridable. - In practice use only
group_vars/,host_vars/, roledefaults, rolevars, and-efor one-offs. Every other source is another place to look. - Use
ansible_facts['x'], not the injected flatansible_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-cacheafter changing a machine. hostvarsbuilds 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_factis per-host, does not accumulate in a loop, andcacheable: truepersists 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=falsepasses 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.