How Ansible Actually Resolves Variables
A practical guide to the Ansible variable precedence rules that cause real playbook surprises.
The variable that will not move
You bump app_port in group_vars/all, re-run the play, and it comes up with the old value. So you
set it again in host_vars, closer to the machine you actually care about. Same result. You grep the
repository, find four definitions, comment out three of them, and the play resolves to a number that
appears in none of the files still on disk.
By then you have stopped debugging the value and started fighting precedence. Precedence does not lose. The one comfort is that it follows a fixed order, and once you can see that order, the value that seems to come from nowhere always has an address.
The mental model most people start with
Most people picture variable resolution as a stack of files where the last file Ansible reads wins. It is a comforting model because it matches how a shell sources config. It also happens to be wrong in one big way and blind to three more.
Precedence is decided by the kind of source, not the order Ansible happened to load it.
host_vars/web01 beats group_vars/all every time, even when the group file is read later. A host
is more specific than a group, and specificity decides it. Timing has nothing to do with it.
The file-order picture also misses three things it cannot see on disk: how specific a source is to the host, how close a source sits to the running task, and values that do not exist until the play is already running. Miss those and the winning definition looks invisible.
The real order: 22 levels, 9 that bite
Ansible documents 22 precedence levels, from least to greatest. The last one to define a variable wins:
- Command line values (for example
-u my_user; not variables) - Role defaults
- Inventory file or script group vars
- Inventory
group_vars/all - Playbook
group_vars/all - Inventory
group_vars/* - Playbook
group_vars/* - Inventory file or script host vars
- Inventory
host_vars/* - Playbook
host_vars/* - Host facts and cached
set_fact - Play vars
- Play
vars_prompt - Play
vars_files - Role vars (
roles/x/vars/main.yml) - Block vars
- Task vars
include_varsset_factand registered vars- Role and
include_roleparams includeparams- Extra vars (
-e, always win)
You will never argue with most of these. Nine cause the bulk of real surprises, and those are the
ones worth building intuition for: role defaults, inventory group_vars/all, group-specific vars,
host_vars, play vars, role vars, task vars, set_fact or registered vars, and extra vars. The rest
are either rare in practice or obvious once you know the shape.
The rule that makes it stick
You do not need all 22 levels in your head. You need three questions.
Which value is closer to the running task? Task vars beat play vars beat inventory. If app_port is
3000 in play vars and 5000 in a task var, the task wins with 5000, because it sits right next
to the work.
Which value is more specific to this host? host_vars/web01 beats group_vars/webservers beats
group_vars/all. One machine outranks a group, and a named group outranks everyone.
Did someone pass an override? -e on the command line wins over all of it. It is the operator’s
final word, and nothing below can reach it.
Closer wins. More specific wins. -e always wins. Almost every precedence surprise is one of those
three questions answered wrong.
The trap: static and dynamic roles
The subtlest source of “where did that come from” is a role you were sure had not run.
import_role is static. Ansible reads the role at parse time, before any task executes, and its
defaults and vars join the play immediately:
# roles/web/defaults/main.yml
app_port: 8080
# site.yml
- hosts: web
tasks:
- import_role:
name: web
when: false # skip the role's tasks
- debug:
var: app_port # still prints 8080
The when: false skips every task in the role, but the variables loaded before the condition was
ever checked. The role can lose all of its tasks and still win the value.
include_role is dynamic. Ansible resolves it when the play reaches it, and its variables stay
scoped to the role’s own tasks:
# site.yml
- hosts: web
tasks:
- include_role:
name: web
when: false # the role is never reached
- debug:
var: app_port # "VARIABLE IS NOT DEFINED!"
The when:false trap
The tasks are skipped in both cases. Flip the invocation to see whether the role’s values reach the play.
Switch the trap from import_role to include_role and the role’s value drops out of the rest of
the play. The one caveat that keeps this honest: include_role will expose its variables if you ask,
with public: true. Set that and a dynamic include starts behaving like a static one for variables.
The sleeper: set_fact and registered variables
The other value that appears from nowhere is one you created earlier in the same run.
Anything you set with set_fact, or capture with register, lands at level 19, above inventory,
play, role, and task vars. It also persists for the rest of the play:
# inventory group_vars/all.yml
app_port: 80
# site.yml
- hosts: web
tasks:
- set_fact:
app_port: 9999
- debug:
var: app_port # prints 9999, not 80
When group_vars looks like it is being ignored, check whether an earlier task already decided the
value. A fact computed during setup quietly outranks the inventory you have been staring at.
Run a provided resolution
Here is a provided example. Click Run Playbook and watch Ansible resolve app_port. No setup
needed.
# inventory/group_vars/webservers.yml
app_port: 80
# playbooks/show-port.yml
- hosts: webservers
vars:
app_port: 3000
tasks:
- name: debug var=app_port
ansible.builtin.debug:
var: app_port
Press Run Again to watch the -e app_port=1234 override take the final say.
The same tools work on your own playbooks: ansible-inventory --host <host> shows a host’s merged
vars, ansible-playbook <playbook> -vvv traces resolution as it runs, and -e app_port=... forces
the final override.
Build the intuition by playing
A precedence chart is a fine reference, but you will not remember it from reading. You remember it from watching the winner change as you flip sources on and off.
The Variable Resolver lab lets you do exactly that: build the case that resembles your own playbook, switch the role between static and dynamic, and watch which definition survives. Spend three minutes losing to precedence on purpose, and the next time it happens in production you will already know where to look.
Test yourself with three challenges
Open the Variable Resolver and turn on Challenge mode. Three guided problems test whether you can make a value win, reproduce the static-role trap, and fix it.