Ansible Variable Precedence

Why a variable has the wrong value — and how to trace it. The full priority chain explained.

Why precedence matters

Ansible resolves variables from many sources at once. When the same variable name appears in multiple places, one value wins. Understanding which source wins is the key to debugging "why does the config have the wrong value?"

The rule is simple: higher in the list = wins. But knowing the list by heart is what separates someone who debugs quickly from someone who guesses.

The full precedence chain

From lowest (easiest to override) to highest (hardest to override):

  1. Role defaultsroles/rolename/defaults/main.yml
  2. Inventory vars — variables set directly in hosts.ini on a group or host line
  3. group_vars/all — applies to every host
  4. group_vars/groupname — applies to one group
  5. host_vars/hostname — applies to one host
  6. Play varsvars: block in the playbook play
  7. Role varsroles/rolename/vars/main.yml (note: higher than group_vars)
  8. Task varsvars: block inside a specific task
  9. Extra vars-e "varname=value" on the command line
Role vars beat group_vars. This surprises people. roles/nginx/vars/main.yml has higher precedence than group_vars/webservers.yml. Put variables users should be able to override in defaults/, not vars/.

Where to put your variables (decision guide)

Role defaults (lowest priority)

Defined in roles/rolename/defaults/main.yml. These are the documented public interface of your role. Everything a caller can configure should have a default here.

# roles/nginx/defaults/main.yml
---
nginx_port: 80
nginx_worker_processes: auto
nginx_client_max_body_size: 1m
enable_tls: false

Because defaults are the lowest priority, any group_vars, host_vars, or -e override will silently win. This is intentional — defaults should be safe starting points, not requirements.

Inventory and group_vars

The most common place to set production values is in group_vars/. These override role defaults.

# inventories/production/group_vars/all.yml
---
# Overrides the role default of 0.pool.ntp.org
chrony_servers:
  - ntp1.internal.example.com
  - ntp2.internal.example.com
# inventories/production/group_vars/webservers.yml
---
# Overrides nginx defaults for this group
nginx_port: 443
nginx_client_max_body_size: 64m
enable_tls: true

If a host belongs to multiple groups and both group_vars files define the same variable, the result is not simply alphabetical — it depends on inventory structure, group depth, and ansible_group_priority. This is a common source of subtle bugs. Avoid it by using host_vars, restructuring groups, or setting ansible_group_priority explicitly on the group you want to win.

host_vars (beat group_vars)

host_vars/hostname.yml beats all group_vars. Use it for genuine per-host differences.

# inventories/production/host_vars/web01.yml
---
# This host runs on a non-standard port due to a legacy app
nginx_port: 8443
nginx_server_name: web01.example.com
Keep host_vars minimal. If you find yourself defining the same variable in host_vars for every host, it should be in a group or all.yml instead.

Play vars and task vars

Variables defined directly in a play or task. Useful for one-off runs or when you need a value that is not environment-specific.

- name: Deploy with custom port this run
  hosts: webservers
  vars:
    nginx_port: 9090        # overrides group_vars for this play only
  roles:
    - nginx
- name: Override only for this one task
  ansible.builtin.template:
    src: nginx.conf.j2
    dest: /etc/nginx/nginx.conf
    validate: nginx -t -c %s
  vars:
    nginx_port: 9090        # only affects this task's variable context

Extra vars (highest priority)

Passed on the command line with -e. Nothing overrides them. Use for emergency changes or testing without editing files.

# Override one variable for this run
ansible-playbook site.yml -e "nginx_port=9090"

# Override multiple
ansible-playbook site.yml -e "nginx_port=9090 enable_tls=false"

# Pass a YAML file
ansible-playbook site.yml -e @overrides.yml
Extra vars are not saved. If you use -e to fix something in an emergency, the repo still has the old value. Follow up with a proper change to the right file.

Inspecting variable values

The fastest way to see what value Ansible will actually use for a variable on a specific host:

# Print the value of a variable for all hosts
ansible all -i inventories/production/hosts.ini -m debug -a "var=chrony_servers"

# Print for one host
ansible web01 -i inventories/production/hosts.ini -m debug -a "var=nginx_port"

# Print all variables for a host (verbose — lots of output)
ansible web01 -i inventories/production/hosts.ini -m debug -a "var=hostvars[inventory_hostname]"

Or add a debug task to a playbook temporarily:

- name: DEBUG — show variable value
  ansible.builtin.debug:
    msg: "nginx_port is {{ nginx_port }}, enable_tls is {{ enable_tls }}"

Common confusion scenarios

Variable appears correct in group_vars but role uses wrong value

Most likely cause: the role has the variable in vars/main.yml (not defaults/). Role vars beat group_vars. Move it to defaults.

host_vars value is not being picked up

Check the file is in the right place: inventories/production/host_vars/EXACT_HOSTNAME.yml. The filename must match the host as written in the inventory exactly — including any domain suffix.

Extra vars override everything unexpectedly

Someone ran the playbook with -e previously in a pipeline. Check CI/CD job definitions for hardcoded -e flags that might be overriding intended values.

Variable is undefined even though I set it

Check for typos in variable names. YAML is case-sensitive — Nginx_Port and nginx_port are different variables. Run the debug module to confirm the actual name Ansible sees.

set_fact, registered vars, include_vars

These three sources sit above the standard precedence table and cause the most debugging surprises.

set_fact — create or override a variable mid-play

- name: Build the config path from variables
  ansible.builtin.set_fact:
    config_path: "/etc/{{ app_name }}/{{ env }}.conf"

# The variable is now available for every subsequent task in the play
- name: Template the config
  ansible.builtin.template:
    src: app.conf.j2
    dest: "{{ config_path }}"

set_fact variables persist for the rest of the play (across roles). They beat host_vars and group_vars but lose to -e extra vars.

Registered variables — capture task output

- name: Check if config file exists
  ansible.builtin.stat:
    path: /etc/myapp/myapp.conf
  register: conf_stat

- name: Only proceed if file exists
  ansible.builtin.debug:
    msg: "Config size is {{ conf_stat.stat.size }} bytes"
  when: conf_stat.stat.exists

The register: output structure depends on the module. Use - debug: var=conf_stat to inspect its full structure.

include_vars — load a variable file at runtime

# Load a file based on OS family
- name: Load OS-specific variables
  ansible.builtin.include_vars:
    file: "vars/{{ ansible_os_family }}.yml"

# Load a directory of variable files
- name: Load all vars from directory
  ansible.builtin.include_vars:
    dir: vars/
    extensions: [yml]

Useful for OS-specific defaults. include_vars is dynamic, so it can use facts gathered earlier in the play.

vars_files and vars_prompt

# vars_files — static, loaded at play parse time
- name: Configure app
  hosts: appservers
  vars_files:
    - vars/common.yml
    - vars/{{ env }}.yml   # environment-specific overrides

# vars_prompt — interactive input (not suitable for CI/CD)
- name: Deploy with confirmation
  hosts: prod
  vars_prompt:
    - name: deploy_version
      prompt: "Enter the version to deploy"
      private: false

combine filter and hash_behaviour

The combine filter merges dictionaries, which solves the "I want group_vars defaults but allow host_vars to override individual keys" problem.

# In group_vars/all/vars.yml — base defaults
nginx_settings:
  worker_processes: auto
  keepalive_timeout: 65
  server_tokens: "off"

# In host_vars/web01.yml — override just one key
nginx_settings_override:
  keepalive_timeout: 30
# In the role task — merge at runtime
- name: Build merged nginx settings
  ansible.builtin.set_fact:
    nginx_merged: "{{ nginx_settings | combine(nginx_settings_override | default({})) }}"

Without combine, defining any key in nginx_settings at a higher-precedence source replaces the entire dict, losing all the defaults.

hash_behaviour: Ansible has a global hash_behaviour = merge setting in ansible.cfg that auto-merges dicts, but this is not recommended — it changes behaviour globally and makes playbooks harder to reason about. Use combine explicitly instead.

Vault variable file pattern

The standard pattern for secrets in an Ansible repo is to split group_vars into a plain file and an encrypted vault file.

Directory layout

group_vars/
  all/
    vars.yml      # Plain — references vault_ variables
    vault.yml     # Encrypted with ansible-vault

vault.yml (encrypted)

# Contents before encryption:
vault_db_password: "s3cr3tP@ssword"
vault_api_key: "abc123xyz"
# Encrypt the file
ansible-vault encrypt group_vars/all/vault.yml

# Edit in place (decrypts, opens editor, re-encrypts on save)
ansible-vault edit group_vars/all/vault.yml

vars.yml (plain — references vault vars)

# Use vault_ prefix by convention so you know which vars are secrets
db_password: "{{ vault_db_password }}"
api_key: "{{ vault_api_key }}"

The plain file is safe to read in code review. Secrets never appear unencrypted in version control. Your templates and tasks reference db_password (not vault_db_password) — vault is an implementation detail.

Running playbooks with Vault

# Prompt for password at runtime
ansible-playbook site.yml --ask-vault-pass

# Use a password file (for CI/CD — store the file securely)
ansible-playbook site.yml --vault-password-file ~/.vault_pass.txt