Precedence: When Two Places Disagree

Scenario: Every host is serving port 7777. The group file says 8081, the host file says 9999, and nobody can explain where 7777 came from - until someone finds a vars: block added during an incident six weeks ago.

New words, in plain English

Ansible resolves conflicts with a fixed, documented order. You do not need to memorise all twenty-two levels, but you must know the shape, from weakest to strongest:

  1. Role defaults (roles/x/defaults/main.yml) - lowest, always overridable.
  2. Inventory group vars, with all weakest and more specific groups stronger.
  3. Inventory host vars - a host beats its groups.
  4. Play vars (vars:, vars_files:) - beats everything from the inventory.
  5. Role vars (roles/x/vars/main.yml) and set_fact - higher still.
  6. Extra vars (-e) - absolute winner.

The scenario above is the classic trap: a play-level vars: block outranks both group_vars and host_vars, so the carefully-structured inventory becomes decoration. That is why "temporary" incident overrides in a play are so dangerous - they are invisible from the inventory and they silently win.

The design guidance falls straight out of the order: role defaults/ for tunables, group_vars/ for environment and tier settings, host_vars/ for documented exceptions, -e for emergencies only. Keep user-configurable values out of role vars/, because their high priority makes them effectively un-overridable.

Analogy: A dress code: the company handbook is the default, a department can be stricter, an individual can hold a documented exemption, and a manager shouting an instruction on the day beats all three. If the manager shouts every day, the handbook has stopped meaning anything.

A worked example

# roles/webapp/defaults/main.yml   (weakest - meant to be overridden)
webapp_port: 8080

# group_vars/all.yml               (estate-wide)
app_env: production

# group_vars/webservers.yml        (beats group_vars/all)
webapp_port: 8081

# host_vars/lb01.yml               (beats its groups - a real exception)
webapp_port: 8088

# In a play - beats BOTH of the above. Use sparingly.
vars:
  webapp_port: 7777

# On the command line - beats absolutely everything.
ansible-playbook site.yml -e webapp_port=9999

Do not read files to find out what won - ask Ansible. ansible-inventory --host web01 prints every inventory-sourced variable for that host. A debug task prints the fully-resolved value at exactly the point the play would use it, which also captures play vars, role vars and extra vars that ansible-inventory cannot see.

One more rule that catches people out: when a host belongs to several groups at the same level, the last group loaded wins, and load order is alphabetical unless you set ansible_group_priority. Relying on that is fragile; if two sibling groups both define a variable, restructure rather than depend on the ordering.

Warning: A vars: block in a play beats both group_vars and host_vars. An override added during an incident and never removed will silently defeat your entire inventory structure.
Goal: Put this to work in the ansible-variable-precedence lab. Open /labs/ansible, pick ansible-variable-precedence, and fix the real broken project - Ansible really does SSH into four managed hosts and converge them.