Chapter 5: Variables & Facts
Topic 27

Variable Precedence

ConceptVariables

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.

The practical precedence ladder — weakest at the floor, strongest at the ceiling
9
-e / --extra-varsThe ceiling — an operator override for one run, beats every source in the repo.
8
set_fact & registered varsComputed mid-run; shadows nearly every committed source below it.
7
block & task vars:Set right on the block or task that runs.
6
role vars/main.ymlA role's near-fixed internals — outranks play vars and group_vars.
5
play vars:Defined in the play; beats all inventory data.
4
host_vars/One box's values — the most specific inventory tier.
3
group_vars/<group>A specific group like web; beats all.
2
group_vars/allFleet-wide defaults — timezone, base packages.
1
role 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.

Same Variable, Two Sources — Who Wins

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.

Common Mistakes
  • Setting a value you intend as a hard requirement in role defaults/main.yml and watching a stale group_vars/all quietly 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's group_vars are silently dead — every reviewer edits the inventory and nothing changes because -e outranks it.
  • Defining the same nginx_port in both group_vars/web.yml and a play vars:, then editing the group file expecting the change to take — play vars: outrank inventory, so the edit does nothing.
  • Assuming host_vars always wins because it's "most specific" — a play var, a set_fact, a role var, and -e all sit above host_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 a debug task — the 22 levels are exactly where intuition fails and the resolved value must be observed.
Best Practices
  • Define overridable knobs once in role defaults/, real per-environment values in group_vars, and reserve -e for 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 inline debug: var=<name> before editing any file, so you change the source that actually wins.
  • Keep -e out of committed CI pipelines except for the environment selector — anything else in -e makes 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.
Comparable tools Chef a documented 15-level attribute precedence — the closest analog Puppet Hiera hierarchy resolution Salt pillar layering Terraform env vars < .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 defaults is the weakest — meant to be overridden — and -e is the strongest, overriding every other source
  • -e is the weakest because it is transient, while role defaults is 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_vars quietly 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_vars value, 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
  • all is 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 web ends up overwriting all purely by filename sort order
  • Values placed in all apply 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 -e silently shadows every variable file and makes reviewers' edits to the repo do nothing
  • It overrides only the role defaults tier, 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_vars but quietly loses to host_vars, so committed per-host inventory config still reliably wins in a CI run

You got correct