In Ansible, a variable resolves to an unexpected value at runtime. How does Ansible's variable precedence decide which definition wins, and how do you trace where the value actually came from?
answer
- a ladder, not a lookup
- role defaults are the floor
- -e beats everything, no exceptions
- host vars beat group vars
- debug var, then ansible-inventory --list
basics
~20 sAnsible layers about 22 variable sources from role defaults at the bottom to command-line extra vars at the top, which always win. Facts sit below play vars; set_fact and registered vars sit near the top. Trace a value with the debug module and ansible-inventory.
solid answer
~40 sAnsible resolves a name by walking a fixed ladder of roughly 22 sources rather than by file order. The floor is a role's `defaults/main.yml` — that is why defaults are the right place for anything a consumer should override. Then come inventory group vars, then host vars (host beats group), then gathered facts, then play `vars` / `vars_prompt` / `vars_files`, then role `vars/main.yml`, block vars, task vars, `include_vars`, `set_fact` and registered results, then role and include params, and finally `--extra-vars` (`-e`), which always wins. In practice nobody recites the list: I print the value with the `debug` module (`var: myvar`), dump everything for the host with `ansible -m debug -a "var=hostvars[inventory_hostname]"`, check what inventory contributes via `ansible-inventory --list`, and grep `group_vars/`, `host_vars/` and the roles for the name.
code
bash · 11 lines# What does inventory alone resolve for this host?
ansible-inventory --host web1
# What does the host actually see at run time?
ansible web1 -m ansible.builtin.debug -a "var=app_port"
# Dump every variable visible to the host
ansible web1 -m ansible.builtin.debug -a "var=hostvars['web1']"
# Force a value past every other source
ansible-playbook site.yml -e app_port=9000go deeper
Know that a variable can be defined in several places and that one fixed order decides the winner. Be able to say that -e on the command line always wins and that role defaults are the weakest source.
Be ready to explain the groupings — role defaults at the bottom, host vars over group vars, facts below play vars, set_fact and registered vars near the top, extra vars on top — and to show the debug and ansible-inventory commands you would run.
Show a repeatable diagnosis on a real repository: separate inventory-sourced values from play-sourced ones, check CI for a stray -e, and explain why dictionaries replace rather than merge and what that does to a half-overridden config.
Own the convention that keeps precedence out of incident reviews: which layer each kind of value is allowed to live in, whether hash_behaviour merge is ever permitted, and how you keep operators' break-glass -e usage auditable rather than routine.
## Why there is a ladder at all Ansible deliberately lets the same variable name be defined in many places: a role ships a sane default, an inventory group narrows it for staging, a single host overrides it again, and an operator forces a one-off value on the command line. For that to be usable there has to be a single, documented, total order — and there is. Ansible resolves a name by consulting sources from weakest to strongest and taking the strongest definition. It is not "last file loaded wins" and it is not alphabetical. ## The order, grouped so you can actually remember it The documented ladder for ansible-core 2.x, lowest first, is: 1. command line values that are not variables (`-u`, `-b`) 2. role defaults (`defaults/main.yml`) 3.–7. inventory group vars, then `group_vars/all`, then `group_vars/<group>` (inventory-adjacent before playbook-adjacent) 8.–10. inventory host vars, then `host_vars/<host>` 11. host facts and cached `set_fact` values 12.–14. play `vars`, `vars_prompt`, `vars_files` 15. role `vars/main.yml` 16.–17. block vars, then task vars 18. `include_vars` 19. `set_fact` and registered variables from this run 20.–21. role / `include_role` params, then include params 22. extra vars (`-e` / `--extra-vars`) — always win Four groupings carry almost all the interview value: - **Role `defaults/` is the floor, role `vars/` is high.** Same directory tree, opposite intent: `defaults/main.yml` is "override me", `vars/main.yml` is "internal, do not override". Putting a tunable in `vars/` and then wondering why `group_vars` is ignored is the single most common real-world confusion. - **Host beats group.** `host_vars/web1.yml` overrides `group_vars/web.yml`. Within groups, a child group beats its parent, and `ansible_group_priority` breaks ties between groups at the same depth. - **Facts sit *below* play vars.** A play-level `vars:` entry named `ansible_distribution` shadows the gathered fact. This is why reading facts through the `ansible_facts` dictionary (`ansible_facts['distribution']`) is safer than the injected top-level `ansible_distribution` name, which the `inject_facts_as_vars` setting can disable entirely. - **`-e` always wins.** There is no way to protect a variable from extra vars. That is a feature for break-glass operations and a hazard for anything you wanted to be constant. ## Merging, not just winning By default Ansible *replaces* a whole dictionary rather than merging it: if `group_vars/all.yml` sets `app: {port: 80, user: app}` and `host_vars/web1.yml` sets `app: {port: 8080}`, web1 loses `user`. The global `hash_behaviour = merge` setting in `ansible.cfg` changes this, but it is discouraged because it changes semantics for every variable in the project and makes roles behave differently between repositories. The maintainable answer is to keep the dictionary flat, or to merge explicitly with the `combine` filter: ```yaml app_config: "{{ app_defaults | combine(app_overrides, recursive=True) }}" ``` ## How you actually debug it Reciting 22 levels is not the answer an interviewer wants. Demonstrate a procedure: ```yaml - name: What is the value here, right now ansible.builtin.debug: var: app_port - name: Everything this host can see ansible.builtin.debug: var: hostvars[inventory_hostname] verbosity: 2 ``` Outside a playbook, the ad-hoc form is fastest: `ansible web1 -m ansible.builtin.debug -a "var=app_port"`. To separate *inventory* contributions from playbook ones, `ansible-inventory --list` (or `--host web1`, or `--graph --vars`) prints exactly what the inventory sources resolve to, with no play involved — if the surprising value shows up there, the culprit is a `group_vars`/`host_vars` file; if it does not, the culprit is inside the play, a role, or `-e`. After that: run with `-vvv` to see the arguments the module was actually invoked with (the rendered value, not the expression), and grep the repository for the name across `group_vars/`, `host_vars/` and every role's `defaults/` and `vars/`. Check the wrapper script or CI job for a stray `-e` — a value that ignores every file you edit is almost always extra vars. ## The trap behind the trap Jinja expressions are evaluated lazily, at the moment the variable is used, not where it is written. A variable defined as `"{{ base_url }}/health"` picks up whichever `base_url` wins *at task time*, so the same definition can render differently in two plays of the same run. When you are chasing a value, note *where* it was consumed, not only where it was defined.
- A role defines the same variable in both defaults/main.yml and vars/main.yml — which one applies?`vars/main.yml` wins by a wide margin: role defaults are the lowest precedence source in Ansible, role vars sit above play vars and `vars_files`. The convention follows from that — `defaults/` holds anything a consumer is expected to override, `vars/` holds role-internal constants that inventory should not be able to change.
- Can a play-level vars entry override a gathered fact such as ansible_distribution?Yes. Host facts sit below play `vars`, `vars_prompt` and `vars_files` in the ladder, so a play variable of the same name shadows the fact. It is a real source of confusing bugs, which is why reading facts through `ansible_facts['distribution']` is safer than relying on the injected top-level `ansible_distribution` name.
- How do you make a variable that nobody downstream can override?You cannot fully — `--extra-vars` always wins. The nearest practical protections are to set the value in role `vars/main.yml` or as a task var, both of which sit above inventory and play vars, and to enforce the intended value with an assertion in the play rather than trying to make the assignment tamper-proof.
It works like CSS specificity: many rules can name the same property, and one fixed scoring order decides the winner — with --extra-vars playing the role of !important.
saying these in an interview costs you the question
- Says group_vars always overrides host_vars
- Claims set_fact can beat --extra-vars
- Thinks precedence is last-file-loaded or alphabetical
- Recites all 22 levels instead of showing a debugging method
- Assumes gathered facts outrank play vars