·7 min

How Ansible Actually Resolves Variables

A practical guide to the Ansible variable precedence rules that cause real playbook surprises.

The variable that will not move

You bump app_port in group_vars/all, re-run the play, and it comes up with the old value. So you set it again in host_vars, closer to the machine you actually care about. Same result. You grep the repository, find four definitions, comment out three of them, and the play resolves to a number that appears in none of the files still on disk.

By then you have stopped debugging the value and started fighting precedence. Precedence does not lose. The one comfort is that it follows a fixed order, and once you can see that order, the value that seems to come from nowhere always has an address.

The mental model most people start with

Most people picture variable resolution as a stack of files where the last file Ansible reads wins. It is a comforting model because it matches how a shell sources config. It also happens to be wrong in one big way and blind to three more.

Precedence is decided by the kind of source, not the order Ansible happened to load it. host_vars/web01 beats group_vars/all every time, even when the group file is read later. A host is more specific than a group, and specificity decides it. Timing has nothing to do with it.

The file-order picture also misses three things it cannot see on disk: how specific a source is to the host, how close a source sits to the running task, and values that do not exist until the play is already running. Miss those and the winning definition looks invisible.

The real order: 22 levels, 9 that bite

Ansible documents 22 precedence levels, from least to greatest. The last one to define a variable wins:

  1. Command line values (for example -u my_user; not variables)
  2. Role defaults
  3. Inventory file or script group vars
  4. Inventory group_vars/all
  5. Playbook group_vars/all
  6. Inventory group_vars/*
  7. Playbook group_vars/*
  8. Inventory file or script host vars
  9. Inventory host_vars/*
  10. Playbook host_vars/*
  11. Host facts and cached set_fact
  12. Play vars
  13. Play vars_prompt
  14. Play vars_files
  15. Role vars (roles/x/vars/main.yml)
  16. Block vars
  17. Task vars
  18. include_vars
  19. set_fact and registered vars
  20. Role and include_role params
  21. include params
  22. Extra vars (-e, always win)

You will never argue with most of these. Nine cause the bulk of real surprises, and those are the ones worth building intuition for: role defaults, inventory group_vars/all, group-specific vars, host_vars, play vars, role vars, task vars, set_fact or registered vars, and extra vars. The rest are either rare in practice or obvious once you know the shape.

Variable Resolver

Toggle a source. The resolved value updates below.

Choose the sourceslowest → highest

Precedence is a fixed order: the highest enabled source wins. Extra vars (-e) always win.

LowestStronger ↓
  1. Inventory · group_vars/all
    80
    on
  2. Inventory · group_vars/webservers
    8443
    on
  3. Inventory · host_vars/web01
    9000
    on
  4. Play vars
    3000
    on
  5. Task vars
    5000
    on
  6. Extra varswinner
    1234
    on
Highest
Resolved app_portlive
app_portlive
app_port=1234
Why this wins

Extra vars (-e) have the highest variable precedence, so nothing below can override them.

ansible-playbook -vvv
TASK [debug var=app_port]
ok: [web01] => {
"app_port": 1234
}
# resolved from extra_vars (overrode 5 lower definitions)

The rule that makes it stick

You do not need all 22 levels in your head. You need three questions.

Which value is closer to the running task? Task vars beat play vars beat inventory. If app_port is 3000 in play vars and 5000 in a task var, the task wins with 5000, because it sits right next to the work.

Which value is more specific to this host? host_vars/web01 beats group_vars/webservers beats group_vars/all. One machine outranks a group, and a named group outranks everyone.

Did someone pass an override? -e on the command line wins over all of it. It is the operator’s final word, and nothing below can reach it.

Closer wins. More specific wins. -e always wins. Almost every precedence surprise is one of those three questions answered wrong.

The trap: static and dynamic roles

The subtlest source of “where did that come from” is a role you were sure had not run.

import_role is static. Ansible reads the role at parse time, before any task executes, and its defaults and vars join the play immediately:

# roles/web/defaults/main.yml
app_port: 8080

# site.yml
- hosts: web
  tasks:
    - import_role:
        name: web
      when: false          # skip the role's tasks
    - debug:
        var: app_port      # still prints 8080

The when: false skips every task in the role, but the variables loaded before the condition was ever checked. The role can lose all of its tasks and still win the value.

include_role is dynamic. Ansible resolves it when the play reaches it, and its variables stay scoped to the role’s own tasks:

# site.yml
- hosts: web
  tasks:
    - include_role:
        name: web
      when: false          # the role is never reached
    - debug:
        var: app_port      # "VARIABLE IS NOT DEFINED!"

The when:false trap

The tasks are skipped in both cases. Flip the invocation to see whether the role’s values reach the play.

Role behaviorstatic vs dynamic

Keep the condition at when: false. Then switch how the role is invoked and watch what reaches the play.

What reaches the play?tasks skipped · vars exposed anyway
Role invocation
Role condition
Resolved app_portlive
app_portlive
app_port=443
Why this wins

Role vars carry the invoked role's specific policy, taking priority over broader play settings.

ansible-playbook -vvv
TASK [debug var=app_port]
skipping: [web01]
# role tasks skipped (when: false)
ok: [web01] => {
"app_port": 443
}
# resolved from role_vars (overrode 1 lower definition)

Switch the trap from import_role to include_role and the role’s value drops out of the rest of the play. The one caveat that keeps this honest: include_role will expose its variables if you ask, with public: true. Set that and a dynamic include starts behaving like a static one for variables.

The sleeper: set_fact and registered variables

The other value that appears from nowhere is one you created earlier in the same run.

Anything you set with set_fact, or capture with register, lands at level 19, above inventory, play, role, and task vars. It also persists for the rest of the play:

# inventory group_vars/all.yml
app_port: 80

# site.yml
- hosts: web
  tasks:
    - set_fact:
        app_port: 9999
    - debug:
        var: app_port      # prints 9999, not 80

When group_vars looks like it is being ignored, check whether an earlier task already decided the value. A fact computed during setup quietly outranks the inventory you have been staring at.

Run a provided resolution

Here is a provided example. Click Run Playbook and watch Ansible resolve app_port. No setup needed.

# inventory/group_vars/webservers.yml
app_port: 80

# playbooks/show-port.yml
- hosts: webservers
  vars:
    app_port: 3000
  tasks:
    - name: debug var=app_port
      ansible.builtin.debug:
        var: app_port
show-port.yml

First run: the play var resolves to 3000. Run Again: -e app_port=1234 wins.

debug var=app_port(ansible.builtin.debug)

Press Run Again to watch the -e app_port=1234 override take the final say.

The same tools work on your own playbooks: ansible-inventory --host <host> shows a host’s merged vars, ansible-playbook <playbook> -vvv traces resolution as it runs, and -e app_port=... forces the final override.

Build the intuition by playing

A precedence chart is a fine reference, but you will not remember it from reading. You remember it from watching the winner change as you flip sources on and off.

The Variable Resolver lab lets you do exactly that: build the case that resembles your own playbook, switch the role between static and dynamic, and watch which definition survives. Spend three minutes losing to precedence on purpose, and the next time it happens in production you will already know where to look.

Test yourself with three challenges

Open the Variable Resolver and turn on Challenge mode. Three guided problems test whether you can make a value win, reproduce the static-role trap, and fix it.