AnsibleXIII · Variables and PrecedenceDesign
-e always wins, and that is the problem
What you'll learn
- Explain why extra vars outrank every other source and what that costs
- Recognise that -e applies uniformly to every host, erasing per-host exceptions
- Distinguish the narrow legitimate uses of -e from habitual use
- Constrain extra vars so a run remains reproducible from the repository
Prerequisites
Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11
Entry 22 of the precedence table has a parenthetical that no other entry has: “always win precedence”.
That is not a ranking, it is a guarantee. There is nothing you can put
in a role, an inventory, a playbook or a task that will survive an -e
on the command line. Extra vars are the one source with no counter.
For an emergency that is exactly right. At 03:00, with a bad value baked into a role you cannot safely edit and a fleet in a degraded state, being able to overrule the entire configuration system with one flag is a genuinely good property. Keep it, and be glad it exists.
The trouble is that the same property makes -e the path of least
resistance for every awkward configuration problem, and an estate that
takes that path stops being describable by its own repository.
What the guarantee actually buys
Recall the estate from lesson 1. web-02 has a deliberate host_vars
exception — two workers, because it runs on older two-core hardware
pending replacement. Every other web host takes the production policy of
sixteen.
$ ansible-playbook site.ymlok: [web-01.example.com] => {
"msg": "port=443 workers=16 log=warn"
}
ok: [web-02.example.com] => {
"msg": "port=443 workers=2 log=warn"
}Now one operator adds one flag:
$ ansible-playbook site.yml -e webapp_worker_count=99ok: [web-01.example.com] => {
"msg": "port=443 workers=99 log=warn"
}
ok: [web-02.example.com] => {
"msg": "port=443 workers=99 log=warn"
}This is the property people miss. Extra vars are not host-scoped.
They are a single global binding applied identically to every host in
the run, and they sit above host_vars, so every deliberate per-host
exception in the estate is erased at once.
The documented reason web-02 was different — two physical cores — did
not stop being true. The mechanism that recorded it was simply
overruled, silently, by a flag typed for an unrelated reason.
What two years of -e looks like
The decay is gradual and each step is locally reasonable.
Month one. A value is wrong in production during an incident. Someone
runs the play with -e to fix it now and open a pull request in the
morning. Correct call.
Month three. The pull request was never opened. The -e is in a
runbook, as a copy-paste line. It works, so nobody revisits it.
Month eight. The scheduled job has three -e flags. They are in the
cron entry, or the CI job definition, or a wrapper shell script — in any
case, not in the Ansible repository. The repository now describes a
configuration nobody actually runs.
Month eighteen. A new engineer reads group_vars/prod.yml, makes a
change, tests it, and ships it. Nothing happens in production, because
an -e three layers up in a Jenkins job has been overriding that
variable since before they joined. They lose a day, and their conclusion
is that the estate is haunted.
Month twenty-four. Four specific properties are gone:
- The repository no longer describes what will happen. Reading it gives you a prediction that is wrong in ways you cannot enumerate.
- Runs are not reproducible. The same playbook, the same inventory and the same commit produce different results depending on who invokes it and from where.
- Review reviews the wrong thing. A pull request against
group_vars/is approved on the assumption it takes effect. Sometimes it does not, and review has no way to tell. - The precedence table predicts nothing. It correctly says
-ewins. It cannot tell you which-eflags exist, because they are not in the repository at all.
The last one is the deepest. Everything else in this part is a technique
for reasoning about a repository. Heavy -e use removes the repository
from the reasoning.
The legitimate uses
There are real ones. They share a shape: the value is genuinely not a property of the infrastructure, and it genuinely differs per invocation.
A one-off operational parameter. A maintenance window, a target release for one deployment, a batch identifier. Something that describes this run rather than these hosts.
ansible-playbook deploy.yml --limit web --extra-vars 'app_release=2026.08.3'
A CI-injected build identifier. The pipeline knows the artefact
version; the repository cannot. This is -e doing exactly what it is
for, and the value is recorded in the pipeline run.
A genuine emergency override. The role is wrong, the fix is not ready, and hosts need correcting now. Use it, then open the pull request — and make the pull request the definition of done, not the successful run.
Everything on that list is a value that varies per run. If a value varies per host or per environment, it belongs in the inventory; that is what the inventory is.
Constraining the ones you keep
Four rules make the difference between controlled use and decay.
Never in a scheduled job. This is the important one. A cron entry,
a systemd timer, an AWX job template or a CI pipeline that carries -e
has moved part of the configuration outside the repository permanently.
If a scheduled run needs a non-default value, put it in the inventory
and let the schedule invoke the play with no overrides.
Record it where the change is recorded. An -e used during an
incident goes in the change ticket, verbatim, including the full command
line. This is the only artefact that will let anyone reconstruct what
happened.
Prefer a file to a flag for anything structured. -e accepts
@filename and reads YAML or JSON from it:
ansible-playbook deploy.yml --extra-vars '@release-2026-08-3.yml'
The file can be committed, reviewed and referenced by the change ticket. It is still entry 22 and still overrides everything, but it is at least an artefact rather than a shell history entry.
Make its use visible in the run. A play can report what it was given and refuse to proceed on values that should never be overridden:
- name: refuse an override of the safety floor
ansible.builtin.assert:
that: webapp_tls_min_version is version('1.2', '>=')
fail_msg: >-
webapp_tls_min_version was overridden to
{{ webapp_tls_min_version }}, below the estate floor of 1.2
An assertion is the one thing -e cannot bypass: the override lands,
and then the play refuses to act on it. Part XXIV covers this pattern as
a general guardrail.
The test
One question tells you whether your -e use is under control:
Given the repository, the inventory and the commit hash, can you predict what a scheduled run will do?
If yes, extra vars are being used the way they should be — occasionally, deliberately, for values that describe a single run.
If the honest answer is “not without checking the job definition”, then the configuration lives in two places, only one of which is reviewed, and the estate has the property the next lesson’s incident depends on.
Knowledge check
Knowledge check · 4 questions
Q1. An estate has a host_vars file limiting web-02 to two workers because it runs on two-core hardware. An operator runs the play with -e webapp_worker_count=99. What happens to web-02?
Q2. A scheduled job that carries -e flags has moved part of the configuration outside the repository, where it is not reviewed.
Q3. Which of these are defensible uses of --extra-vars? Select all that apply.
Q4. Which single test best tells you whether extra vars are under control in an estate?
Passing score: 75%. Answers are checked in this browser.