Skip to main content
RunBook Academy

AnsibleXIII · Variables and PrecedenceVariable sources

Every place a variable can come from

Intermediate⏱ ~16 minansible-playbookansible-inventory

What you'll learn

  • Enumerate every source Ansible can take a variable value from
  • Group those sources by owner rather than by precedence rank
  • Read a repository and state which sources may legitimately set a given variable
  • Recognise a variable defined in two places as a design defect, not a precedence question

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

Not yet marked complete on this device.

An Ansible variable is a name bound to a value for one host during one run. Twenty-two different places can supply that binding, and Ansible resolves conflicts silently, with no warning and no record of which source won.

That is the whole problem this part of the course exists to solve. The usual response is to memorise the precedence table, and the table is worth knowing — the next lesson establishes it by experiment. But precedence is a tie-break, and a tie-break only matters once you have a tie. The more useful skill, and the one that prevents incidents rather than explaining them afterwards, is being able to open a repository and answer a different question:

Which places are entitled to set this variable, and did anyone set it somewhere they should not have?

A well-built estate answers that by inspection. Every variable has one obvious home, a second definition is a review finding, and precedence never has to arbitrate anything. An estate that needs the precedence table to explain its behaviour has already lost the property that makes automation reviewable.

So this lesson catalogues the sources by owner — who writes them, where they live, and what review path they go through — rather than by rank.

The four owners

Every variable source belongs to one of four groups. The grouping is not Ansible’s; it is an operational one, based on who edits the file and what has to happen before that edit reaches production.

OwnerSourcesLives inChanged by
Inventoryinventory file vars, group_vars/, host_vars/the inventory treean infrastructure change, reviewed
Roledefaults/main.yml, vars/main.ymlthe rolea role change, reviewed
Playvars, vars_files, vars_prompt, role paramsthe playbooka playbook change, reviewed
Runtimefacts, set_fact, register, include_vars, --extra-varsnowhere durablewhatever happened during the run

Read that last row carefully. Runtime sources are the ones that do not exist in the repository at all, or exist only as a value computed while the run was in progress. They are the sources that make an estate unreadable, and — as the precedence lesson shows — they sit at the top of the ladder, where they overrule everything a reviewer looked at.

Inventory-owned sources

The inventory describes your infrastructure, so it is the right home for anything that is a property of the infrastructure: which environment a host is in, how big it is, which datacentre it sits in, what it is for.

There are three inventory-owned shapes, and they are not interchangeable.

Variables in the inventory file itself. Set inline, either per host or per group:

[web]
web-01.example.com
web-02.example.com

[web:vars]
webapp_listen_port=443

This form is compact and it is the lowest-priority inventory source. It is fine for a handful of values in a small estate. It scales badly: the file becomes a mix of topology and configuration, and values written as key=value are all strings unless the inventory plugin converts them.

group_vars/<group>.yml. One file per group, keyed by group name. This is where the overwhelming majority of an estate’s configuration belongs, and the next few lessons argue that case in detail.

host_vars/<host>.yml. One file per host. This is for genuine exceptions — the one machine with different hardware, the one host excluded from a policy — and nothing else.

Role-owned sources

A role ships two variable files, at nearly opposite ends of the precedence ladder. The distance between them is deliberate and it is an interface decision.

roles/<name>/defaults/main.yml holds defaults. It is the lowest precedence source in the entire system — lower than anything in the inventory — which means anyone can override it. A value here says: this is a sensible starting point, and callers are expected to change it.

roles/<name>/vars/main.yml holds role vars. These sit above the inventory and above play vars. A value here says: this is internal to the role, and callers should not be changing it.

Putting a value in the wrong one of those two files is not a filing error, it is an interface change. Lesson 5 is entirely about that decision.

Play-owned sources

The playbook can set variables directly:

- name: configure the web tier
  hosts: web
  vars:
    deploy_window: '02:00-04:00'
  vars_files:
    - vars/release-pins.yml
  roles:
    - role: webapp
      webapp_worker_count: 8

Three sources appear there. vars is inline play vars. vars_files loads a file at play scope. The webapp_worker_count passed alongside the role is a role parameter, which is a much higher-precedence source than either — high enough to overrule the inventory, which surprises people who think of it as “just setting a variable for the role”.

vars_prompt is the fourth play-owned source. It asks the operator at run time, which makes it unusable in any non-interactive context, so it belongs in a small number of deliberately manual playbooks and nowhere near a scheduled job.

Runtime-owned sources

These are the ones with no durable home.

  • Facts — gathered from the managed node by the setup module. Everything under ansible_facts, plus the injected ansible_* names. You do not write these; the host reports them.
  • set_fact — a variable computed during the run and attached to the host for the rest of the play.
  • register — the result of a task, stored under a name you choose.
  • include_vars — a file loaded by a task rather than by the play, which is why it ranks above vars_files despite doing a similar job.
  • --extra-vars / -e — supplied on the command line. Beats everything, records nothing.

Facts and register are covered in Part XIV. What matters here is their position: every runtime source outranks every reviewed source. A value established at run time overrules a value someone approved in a pull request, and that asymmetry is the reason the design lessons in this part are prescriptive rather than advisory.

Reading a repository

Here is the habit the rest of this part depends on. Before asking what a variable resolves to, find every place it is defined. grep is the right tool and it is the first one to reach for:

Read-only / Safefind every definition
$ grep -rn 'webapp_worker_count' --include='*.yml' .
./inventory/group_vars/all.yml:2:webapp_worker_count: 4
./inventory/group_vars/prod.yml:1:webapp_worker_count: 16
./inventory/host_vars/web-02.example.com.yml:1:webapp_worker_count: 2
./roles/webapp/defaults/main.yml:2:webapp_worker_count: 1
./roles/webapp/tasks/main.yml:3:    msg: "port={{ webapp_listen_port }} workers={{ webapp_worker_count }} log={{ webapp_log_level }}"

Four definitions and one use. Now the ownership question has an answer you can evaluate:

  • roles/webapp/defaults/main.yml — the role’s default. Correct.
  • inventory/group_vars/all.yml — an estate-wide baseline. Defensible.
  • inventory/group_vars/prod.yml — production policy. Correct.
  • inventory/host_vars/web-02.example.com.yml — a per-host exception. Correct if somebody can say why that host is different.

That is a healthy layout: every definition is inventory-owned or role-owned, each has a review path, and a reader can predict the outcome without running anything. The estate above is the one used throughout this part, and here is what it actually resolves to:

Read-only / Safethe inventory contribution, per host
$ ansible-inventory --host web-02.example.com
{
  "ansible_connection": "local",
  "webapp_listen_port": 443,
  "webapp_log_level": "warn",
  "webapp_worker_count": 2
}

webapp_worker_count is 2: the host_vars exception beat the prod group policy of 16, which beat the all baseline of 4. The role default of 1 does not appear at all, because ansible-inventory only knows about the inventory.

That last point is the single most important limitation of this command, and lesson 3 returns to it. ansible-inventory answers “what does the inventory say”, not “what will the play see”.

Dictionaries do not merge

One behaviour surprises people who expect layering to be additive. When the same variable holds a dictionary and is defined at two levels, the higher-precedence definition replaces the lower one entirely. It does not merge key by key.

# inventory/group_vars/all.yml
webapp_limits:
  memory: 512M
  cpu: 1

# inventory/group_vars/prod.yml - replaces the whole dict
webapp_limits:
  memory: 4G

On a production host webapp_limits is {memory: 4G}. The cpu key is gone, not inherited. ansible-config reports the setting responsible:

Read-only / Safethe hash merge setting
$ ansible-config list | grep -A 3 '^DEFAULT_HASH_BEHAVIOUR'
DEFAULT_HASH_BEHAVIOUR:
choices:
  merge: Any dictionary variable will be recursively merged with new definitions
    across the different variable definition sources.
  replace: Any variable that is defined more than once is overwritten using the
    order from variable precedence rules (highest wins).

The default is replace, and upstream’s own description of the merge alternative carries the warning that changing it “is not recommended as this is fragile”. Take that seriously: merge is a controller-wide setting that changes the meaning of every dictionary variable in every role you use, including roles you did not write. If you need per-key layering, do it explicitly in the place that needs it with the combine filter, where the behaviour is visible in the code rather than in a config file nobody reads.

The catalogue, condensed

Keep this shape in your head; the next lesson supplies the exact ordering.

  • Lowest of everything: role defaults/.
  • Then the inventory, in the order group before host, and inventory-adjacent before playbook-adjacent, with all before named groups.
  • Then the play — facts, play vars, vars_prompt, vars_files.
  • Then the role internals and task scope — role vars/, block vars, task vars.
  • Then everything decided at run timeinclude_vars, set_fact, registered results, role params, include params.
  • Above all of it: --extra-vars.

The shape is more useful than the list, because it tells you the direction of the asymmetry: the further a value is from a reviewed file in the repository, the more power it has. That is a design constraint you have to work against deliberately, and the next seven lessons are about how.

Knowledge check

Knowledge check · 4 questions

  1. Q1. You open an unfamiliar estate and need to know what webapp_listen_port will be on a production host. What is the correct first step?

  2. Q2. Which of these variable sources leave no durable record in the repository? Select all that apply.

  3. Q3. When the same dictionary variable is defined in two group_vars files, Ansible merges the keys, so a key present only in the lower-precedence file survives.

  4. Q4. What does the four-owner grouping - inventory, role, play, runtime - tell you that the precedence table does not?

Passing score: 75%. Answers are checked in this browser.