Skip to main content
Ansible · lesson 2 of 6
Ansible beginner Lesson 2 of 6

Also part of: Cryptography DevSecOps (AppSec in the pipeline)

Variable Precedence: host_vars Beats group_vars, and extra-vars Beats Everything

The same variable name, defined in three places, resolves to three different values depending on where you look — verified by actually stacking group_vars, host_vars, and -e against each other.

By SkillByExample · Updated

ansible variables precedence vault

Ansible resolves a variable’s value by checking several sources in a fixed order — and the same variable name can genuinely mean something different depending on where you look.

group_vars vs. host_vars

# group_vars/local.yml
env_name: staging
# host_vars/localhost.yml
env_name: production
# vars_playbook.yml
---
- name: Show variable precedence
  hosts: local
  tasks:
    - name: Print env_name
      debug:
        var: env_name
ansible-playbook -i inventory.ini vars_playbook.yml
TASK [Print env_name] **********************************************************
ok: [localhost] => {
    "env_name": "production"
}

localhost matches both the local group (via group_vars/local.yml) and its own specific host_vars/localhost.yml — and the host-specific value won. Host-level variables are always more specific than group-level ones, and Ansible’s precedence order consistently favors specific over general.

extra-vars: the override that beats everything

ansible-playbook -i inventory.ini vars_playbook.yml -e env_name=testing
TASK [Print env_name] **********************************************************
ok: [localhost] => {
    "env_name": "testing"
}

-e on the command line overrode both host_vars and group_vars in one shot — extra-vars sits at the very top of Ansible’s precedence order, above every file-based source, precisely so a human or a CI pipeline can force a value at run time without editing anything.

Play-level vars vs. task-level vars

# task_vars_playbook.yml
---
- name: Play-level vs task-level vars
  hosts: local
  vars:
    greeting: from the play
  tasks:
    - name: Print play-level greeting
      debug:
        var: greeting
    - name: Print task-level override
      vars:
        greeting: from the task
      debug:
        var: greeting
ansible-playbook -i inventory.ini task_vars_playbook.yml
TASK [Print play-level greeting] ***********************************************
ok: [localhost] => {
    "greeting": "from the play"
}

TASK [Print task-level override] ***********************************************
ok: [localhost] => {
    "greeting": "from the task"
}

Same variable name, two different values within the same play — the first task sees the play-level default, the second sees its own narrower, task-scoped override. Every task without its own vars: block still falls back to the play’s value.

Encrypting secrets with vault

echo 'db_password: s3cret123' > secrets.yml
ansible-vault encrypt secrets.yml --vault-password-file <(echo mypassword)
cat secrets.yml
$ANSIBLE_VAULT;1.1;AES256
33316661303430643031623764373339376536353439316466663235346162336266656137383335
6562633531393738633265303233633335633066333137350a663464316235616531316131343632
35656434653733376566303831373430306533623631396434313537313263383533623861643733
6430393032613739310a323364323163376430663436333664363435373830336466333439616362
61346431353435376232666561303764663730323863666632613734323863396635

ansible-vault encrypt prints nothing on success — no confirmation message, just a clean exit. The file on disk is now unreadable ciphertext — cat shows the vault header and encrypted bytes, not the password. A playbook referencing secrets.yml still works normally as long as it’s given the vault password at run time:

ansible-playbook -i inventory.ini vars_playbook.yml -e @secrets.yml --vault-password-file <(echo mypassword)
PLAY RECAP *********************************************************************
localhost                  : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Practice

1. Remove host_vars/localhost.yml and confirm group_vars takes over.
rm host_vars/localhost.yml
ansible-playbook -i inventory.ini vars_playbook.yml
TASK [Print env_name] **********************************************************
ok: [localhost] => {
    "env_name": "staging"
}

With the more specific source gone, precedence falls back to the next one down — group_vars/local.yml’s staging, exactly as it would have if host_vars had never existed.

2. Check a host's fully-resolved variables without running any tasks.
ansible-inventory -i inventory.ini --host localhost --vars
{
    "ansible_connection": "local",
    "env_name": "staging"
}

ansible-inventory --vars applies the same precedence resolution a real playbook run would, without executing a single task — the fastest way to answer “what will this variable actually be” before finding out mid-run. It shows every resolved variable for the host, including connection settings from the inventory file itself, not just the ones your own playbook cares about.

3. Try decrypting the vault file with the wrong password.
ansible-vault view secrets.yml --vault-password-file <(echo wrongpassword)
ERROR! Decryption failed (no vault secrets were found that could decrypt) on secrets.yml for secrets.yml

A wrong password fails decryption outright with a clear error — there’s no partial or silent decrypt; either the exact password is provided or the content stays inaccessible.

Next: roles — the standard directory layout for reusable, shareable Ansible content.

Frequently Asked Questions

Why did host_vars win over group_vars for the same variable name?
Ansible's variable precedence is host-specific over group-specific by design — a value that applies to exactly one host is assumed to be a deliberate, more specific override of whatever the group default says. This isn't a special case; it's the general pattern behind precedence order: more specific always beats more general.
Where does -e (extra-vars) fit in the precedence order?
Right at the top — extra-vars passed on the command line override every other source, including host_vars, group_vars, play vars, and role defaults. This is intentional: it's the mechanism for a human or a CI pipeline to deliberately force a value at run time without editing any files.
Why did a task-level vars: block override a play-level vars: block?
Task-level variables are scoped narrower than play-level ones, and narrower scope wins the same way host_vars beats group_vars — a variable declared right where it's used is assumed more specific than one declared for the whole play. The play-level value is still the default for every other task that doesn't override it.
How do I actually check which value a variable resolved to before running a real playbook?
ansible-inventory --host <hostname> --vars prints a host's fully-resolved variable set with precedence already applied, without running any tasks — the fastest way to confirm what a playbook will actually see before finding out the hard way mid-run.
What does ansible-vault actually encrypt?
The literal file contents, using AES256 — a vault-encrypted file is unreadable without the password, whether opened by ansible-playbook, cat, or any other tool, but Ansible transparently decrypts it in memory at runtime for any playbook that has the right vault password. It protects secrets at rest (in git, on disk) without changing how a playbook references the variable.