Skip to content

Lesson 4 of 9 · 24m

Variables, Facts, and the 22-Level Precedence Maze

Where variables can live, why 'host_vars beats group_vars' is only the start, how gathered facts work, and the precedence rules that explain every 'where did that value come from' bug.

A teammate once spent a day chasing why a deploy used the wrong API URL. The value was overridden in five places: role defaults, group_vars, host_vars, a vars: block on the play, and finally an -e on the Jenkins job that nobody remembered. The playbook wasn't broken — the team didn't share a model of which var wins.

Facts: what the host tells you

gather_facts: true (the default) runs the setup module and populates ansible_facts: OS, network, memory, mounts, virtualisation, all of it.

- name: Show the facts I'd template with
  ansible.builtin.debug:
    msg: "{{ ansible_facts['distribution'] }} {{ ansible_facts['distribution_version'] }} on {{ ansible_facts['default_ipv4']['address'] }}"

Facts are per-host and gathered fresh each play — ansible_facts['memtotal_mb'] isn't from last week's cache unless you enable fact caching. On 5,000 hosts, gather_facts: false on plays that don't need them is a real speed win, or switch to gather_subset: '!all' / smart gathering.

Where variables live — from weakest to strongest

Roughly, lowest to highest precedence:

  1. Role defaults/main.yml — the contract layer; meant to be overridden.
  2. Inventory group_vars/all, then group_vars (deeper groups win), then host_vars.
  3. Gathered host facts.
  4. Play vars:, vars_prompt:, vars_files:.
  5. Role vars/main.yml, then block and task vars: — high precedence, not for overridable knobs.
  6. set_fact and registered results during the run.
  7. -e extra vars on the command line — wins everything. Always.

Ansible variable precedence ladder

The mnemonic that matters operationally: defaults are for libraries, host/group vars are for environment, -e is for emergencies and CI parameters. Anything else is a surprise.

register and set_fact: data born during the run

- name: Get package version
  ansible.builtin.command: dpkg-query -W -f='${Version}' nginx
  register: nginx_version
  changed_when: false

- name: Record it for later tasks
  ansible.builtin.set_fact:
    deployed_nginx: "{{ nginx_version.stdout }}"

register captures a task's full result (stdout, rc, changed, module-specific fields). set_fact mints a real host variable visible to later tasks and plays — a common pattern for computed values like "the new instance's IP from the cloud module result."

Magic variables you should know

inventory_hostname, groups['webservers'] (all hosts in a group), hostvars['db01']['ansible_facts'] (another host's facts — enables "template every web node with the DB's IP"), play_hosts, ansible_play_batch (hosts in the current serial slice).

Field notes

  • A YAML value that starts with {{ must be quoted — port: "{{ app_port }}" — or the parser reads it as a dict and the play fails to load.
  • vars/ inside a role outranks inventory vars — if you want operators to tune it, it belongs in defaults/.
  • Debug with ansible.builtin.debug: var=suspicious_variable — cleaner than msg: because it prints the resolved structure.