AnsibleXIII · Variables and PrecedenceDesign
defaults/ and vars/ are a policy decision
What you'll learn
- State the precedence distance between role defaults and role vars, and what it means
- Decide which of the two files a value belongs in, as an interface decision
- Predict exactly which sources can still override a role var
- Recognise the failure mode where an inventory override silently does nothing
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
A role ships two files that both hold variables and differ by one word in the path:
roles/webapp/defaults/main.yml
roles/webapp/vars/main.yml
They sit at entries 2 and 15 of the precedence ladder. Thirteen levels apart — nearly the whole system, with the entire inventory in between. Which file a value goes in is one of the more consequential decisions in a role, and it is routinely made by whichever file the author opened first.
The decision is an interface declaration:
defaults/main.ymlsays “callers may change this.”vars/main.ymlsays “callers may not change this from the inventory.”
That second phrasing is deliberately narrower than the usual “callers may not change this”, and the reason is in the section after next.
The distance, demonstrated
Take a role with one value in each file, and an inventory that tries to override both:
# roles/webapp/defaults/main.yml
webapp_listen_port: 80
# roles/webapp/vars/main.yml
webapp_tls_min_version: '1.2'
# inventory/group_vars/web.yml - the operator overrides both
webapp_listen_port: 443
webapp_tls_min_version: '1.0'
$ ansible-playbook site.ymlTASK [webapp : ansible.builtin.debug] ******************************************
ok: [web-01.example.com] => {
"msg": "port=443 tls=1.2"
}The port moved to 443. The TLS floor stayed at 1.2 despite a
group_vars file explicitly setting 1.0. Same file, same syntax, same
review — one line worked and the other was inert.
In this particular case that is exactly right: a role that refuses to let the inventory weaken its TLS floor is a role doing its job. But the mechanism is the same whether the intent was good or accidental, and an inert configuration line is dangerous precisely because it reads as a working control.
What can still override a role var
“Callers may not change this” is too strong. Role vars are at entry 15, and seven entries sit above them. Two of those matter in practice:
$ ansible-playbook site2.ymlok: [web-01.example.com] => {
"msg": "port=443 tls=1.3"
}$ ansible-playbook site.yml -e webapp_tls_min_version=1.1ok: [web-01.example.com] => {
"msg": "port=443 tls=1.1"
}So the precise statement is:
A value in role
vars/cannot be overridden by anything in the inventory, nor by playvarsorvars_files. It can be overridden by a role parameter, byset_factorinclude_varsinside the role, and by-e.
That is a useful property rather than a loophole. It means vars/ makes
a value not configurable per host or per environment, while leaving
a deliberate, visible path for a caller who genuinely needs to change it
— written at the call site, in the playbook, where a reviewer sees it.
The -e route is the one to be uneasy about, and lesson 6 is about
exactly that.
How to choose
Ask one question: would a reasonable caller ever need a different value?
Yes, or possibly — defaults/main.yml. This covers most things: ports,
paths, sizes, feature flags, package versions, log levels, timeouts.
No, and a different value would break the role — vars/main.yml.
This is a much smaller set than people expect:
- Internal constants. A lookup table the role’s own templates use. Not configuration; implementation detail that happens to live in YAML.
- Platform maps. The package name that differs between Debian and RHEL. Derived from facts, not chosen by an operator.
- Safety floors. A minimum TLS version, a refusal to write
world-writable modes. Values where “configurable” is itself the
defect. Prefer an
assertfor these where you can, because a failed assertion explains itself and a silently-ignored override does not.
Everything else goes in defaults/. When genuinely unsure, choose
defaults/: an override that works when it should not is a visible
mistake somebody can see and correct, while an override that silently
does nothing is invisible and gets escalated to -e.
Platform maps: the good use of vars/
The strongest legitimate use of vars/ is not main.yml at all. It is
per-platform files selected at run time:
roles/webapp/vars/
├── main.yml
├── Debian.yml
└── RedHat.yml
# roles/webapp/vars/Debian.yml
webapp_package_name: apache2
webapp_service_name: apache2
webapp_config_dir: /etc/apache2
# roles/webapp/vars/RedHat.yml
webapp_package_name: httpd
webapp_service_name: httpd
webapp_config_dir: /etc/httpd
# roles/webapp/tasks/main.yml
- name: load platform-specific names
ansible.builtin.include_vars: "{{ ansible_facts['os_family'] }}.yml"
These are facts about the operating system, not decisions an operator
gets to make. An inventory that overrode webapp_package_name would be
declaring the wrong package name for the platform, so making it
un-overridable from the inventory is correct.
Note that include_vars is entry 18 — higher than vars/main.yml at 15
— so a platform file loaded this way beats anything in main.yml. That
is the intended layering: general internal values in main.yml,
platform specialisation on top.
Knowledge check
Knowledge check · 4 questions
Q1. An operator adds a variable to inventory/group_vars/prod.yml to change a role behaviour. The play runs clean and the behaviour does not change. What is the most likely cause?
Q2. Which sources can still override a value set in a role vars/main.yml? Select all that apply.
Q3. Moving a variable from defaults/main.yml to vars/main.yml is a safe internal refactor, because the role behaves identically.
Q4. A role author is unsure whether a value belongs in defaults/ or vars/. Which way should they lean, and why?
Passing score: 75%. Answers are checked in this browser.