Variable Precedence
When the same variable is defined in more than one place — and on a real project it always is — Ansible resolves the conflict through a fixed precedence order with 22 levels, from role defaults at the weakest, meant to be overridden, up to --extra-vars at the strongest, which always wins. This is the single most common cause of "why is my variable not the value I set": the value you edited lost to a source you forgot outranks it.
You do not need all 22 ranks memorized, but you must know the practical tiers people actually collide in, and you must know that -e beats everything. This topic gives you the short ladder that resolves nearly every real conflict, the two ends where intuition breaks, and the two commands that settle an argument instead of letting you guess.
Narrowest-Defined Usually Wins, With Exceptions
The working intuition is "more specific beats more general," and for inventory data it holds cleanly: host_vars beats group_vars, and a specific group beats all. A value attached to one box outranks a value attached to a group, which outranks a value attached to everything. If every source were inventory data, this one rule would be the whole topic.
The intuition breaks at both ends, and that is exactly where people get burned. Role defaults are deliberately the weakest source of all — more specific than nothing, yet beaten by every group var, host var, and play var — because their entire purpose is to be overridden. And -e overrides every other source regardless of how specific that source is; an -e value beats a host_vars value even though a host is far more specific than a command-line flag. Specificity is the rule in the middle, not at the edges.
The Practical Ladder People Actually Hit
Nearly every real conflict is two adjacent rungs of one short ladder, and knowing it resolves the vast majority of "wrong value" incidents. From weakest to strongest: role defaults/, then inventory group_vars/all, then inventory group_vars/<group>, then host_vars/, then play vars:, then role vars/, then block and task vars:, then set_fact and registered vars, and finally -e extra-vars at the top.
Read that ladder twice and the surprises fall out. Role vars/ sits above play vars:, which is why a value buried in a role's vars/main.yml can't be overridden from the play that calls it. A set_fact sits above role vars/ and block and task vars: alike, so a computed value mid-run shadows nearly everything below it. And every one of these loses to -e. The figure below is this ladder, floor to ceiling.
-e / --extra-varsThe ceiling — an operator override for one run, beats every source in the repo.set_fact & registered varsComputed mid-run; shadows nearly every committed source below it.vars:Set right on the block or task that runs.vars/main.ymlA role's near-fixed internals — outranks play vars and group_vars.vars:Defined in the play; beats all inventory data.host_vars/One box's values — the most specific inventory tier.group_vars/<group>A specific group like web; beats all.group_vars/allFleet-wide defaults — timezone, base packages.defaults/main.ymlThe floor — exists to be overridden by anything above.Role defaults Are the Floor, extra-vars Are the Ceiling
defaults/main.yml exists precisely to be overridden, so it sits at the absolute bottom. Any group var, host var, or play var beats it — which is the feature, because a role ships sensible defaults that a consumer tunes without touching the role. The flip side is the trap: defaults is the wrong place to enforce a hard requirement, since a stale value anywhere above silently wins instead.
-e sits at the absolute top so an operator can force a value for one run — ansible-playbook site.yml -e "larkspur_web_nginx_port=8443" — and nothing committed in the repo can countermand it. That is exactly why -e is dangerous in CI: pin a value with -e in a pipeline config and the repository's group_vars become a lie, because every reviewer edits the inventory file and nothing changes. Reserve -e for genuine one-off overrides, never for standing pipeline configuration.
group_vars/all vs Group vs Host
The everyday Larkspur case lives entirely in the inventory tiers. A default in group_vars/all.yml — the timezone, the base package set — is overridden by group_vars/web.yml for web-tier settings, which is in turn overridden by host_vars/db1.larkspur.io.yml for one box's tuning. The wider the scope, the weaker the value, so the same nginx_workers set fleet-wide, then per tier, then per box, resolves to the most specific definition for each host.
This is the half of precedence that matches intuition perfectly, and it is where the bulk of a clean project's variables live. Fleet-wide truths go in all, tier-wide truths go in the group file, and the rare per-box exception goes in host_vars — each overriding the wider tier below it, no command-line flags or role internals involved.
Inventory vs Playbook group_vars, and the Full 22
The short ladder hides a wrinkle: there are actually two group_vars tiers, one adjacent to the inventory file and one adjacent to the playbook, and the playbook-adjacent set outranks the inventory-adjacent one. Beyond those, the full 22 levels include vars_prompt, vars_files, role parameters passed at include time, and include_vars — rungs you rarely collide on but that exist in the ordered list the Ansible docs publish.
Day to day you reason with the section-two ladder and consult the full table only for the rare deep conflict where two obscure sources fight. The point is not to memorize 22 rungs; it is to know that the table is complete and ordered, so when intuition fails on an exotic case there is an authoritative answer to look up rather than a guess to make.
Debugging a Precedence Conflict
When a value is wrong, do not reason from memory — the 22 levels are exactly where intuition fails, and the resolved value must be observed. ansible-inventory --host web1.larkspur.io prints every inventory variable resolved for that host, showing which group_vars/host_vars value survived. A debug: var=larkspur_web_nginx_port task shows the actual value at that exact point in the run, after every source above inventory has had its say.
And ansible-playbook ... -e "larkspur_web_nginx_port=9999" is the proof: if the run suddenly uses 9999, a higher source was shadowing your edit, because -e beats everything. These three checks — resolve the inventory, debug at the task, force with -e — turn "I edited the file and nothing happened" from a mystery into a five-minute diagnosis.
group_vars/all vs group_vars/web — the more specific group (web) wins, every time. A fleet-wide default in all always yields to a tier-specific value, so set base truths in all and tier overrides in the group file.
host_vars/web1 vs play vars: — the play var wins; play-level beats inventory-level. A host-specific value loses here, which surprises anyone who assumes "most specific always wins." When a value surprises you, walk it up the ladder before editing anything.
anything vs -e — -e wins; extra-vars override the entire stack, repo and inventory and play alike. That power is why an -e left in a CI pipeline silently kills every variable file underneath it.
- Setting a value you intend as a hard requirement in role
defaults/main.ymland watching a stalegroup_vars/allquietly override it on every host — defaults are the weakest tier, not a place to enforce anything. - Pinning a value with
-e"just for this run" in a CI job and leaving it in the pipeline config, so the repo'sgroup_varsare silently dead — every reviewer edits the inventory and nothing changes because-eoutranks it. - Defining the same
nginx_portin bothgroup_vars/web.ymland a playvars:, then editing the group file expecting the change to take — playvars:outrank inventory, so the edit does nothing. - Assuming
host_varsalways wins because it's "most specific" — a playvar, aset_fact, a rolevar, and-eall sit abovehost_vars, so a host-level value loses to any of them. - Reasoning about precedence from memory under pressure instead of running
ansible-inventory --host <host>and adebugtask — the 22 levels are exactly where intuition fails and the resolved value must be observed.
- Define overridable knobs once in role
defaults/, real per-environment values ingroup_vars, and reserve-efor genuine one-off operator overrides — one variable, one home, chosen by its scope. - When a value is wrong, resolve it with
ansible-inventory --host <host>and an inlinedebug: var=<name>before editing any file, so you change the source that actually wins. - Keep
-eout of committed CI pipelines except for the environment selector — anything else in-emakes the repository's variable files a lie. - Memorize the short ladder — defaults < group_vars/all < group < host < play vars < role vars < task vars < set_fact < extra-vars — and consult the full 22-level table only for the rare deep conflict.
- Treat role
defaults/as the floor and never as a requirement enforcer — if a value must hold, put it where nothing weaker can shadow it, not where everything above it can.
.tfvars < -var, a far shallower order
Knowledge Check
Of role defaults and -e extra-vars, which is the weakest source and which is the strongest?
- Role
defaultsis the weakest — meant to be overridden — and-eis the strongest, overriding every other source -eis the weakest because it is transient, while roledefaultsis the strongest because it ships baked into the role- They sit at exactly the same precedence level, so the one defined later in the file simply wins
- Both are mid-tier sources, and inventory
host_varsquietly outranks each of them
A variable is set in both host_vars/web1 and the play's vars: block. Which value does the task see?
- The play
vars:value — play-level precedence beats inventory-level, even though a host is more specific - The
host_varsvalue, because a single host is the most specific scope and most-specific always wins outright - Whichever of the two files Ansible happens to load last as it walks the run
- Neither — defining the same name in two places raises a precedence conflict error and aborts the play
Why does a value in group_vars/all lose to one in group_vars/web?
- A more specific group outranks the fleet-wide
all, so the tier-specific value wins for hosts in that group allis only ever read as a fallback when no other group file exists on disk- The two group files are merged in strict alphabetical order, so
webends up overwritingallpurely by filename sort order - Values placed in
allapply only to the ungrouped hosts left over in the inventory
What does -e override, and why does that make it risky to leave in a CI pipeline?
- It overrides every other source, so a forgotten
-esilently shadows every variable file and makes reviewers' edits to the repo do nothing - It overrides only the role
defaultstier, so the practical risk stays limited to a single role's baseline - It overrides nothing at runtime and only stages values for the next run, so a stale pipeline value is harmless
- It overrides ordinary
group_varsbut quietly loses tohost_vars, so committed per-host inventory config still reliably wins in a CI run
You got correct