Sign in
← Ansible
15 min · medium · Lesson 8 of 12

Variables, facts, and precedence

Where to declare variables, what facts are (ansible_facts), register, set_fact, and how Ansible resolves conflicts by precedence.

One playbook, several environments: dev listens on port 8080, prod on 443. Log paths differ, package versions differ. Copying the playbook for every environment means editing the same thing in five places later.

Variables solve this. You write the logic once, and feed in the actual values from outside depending on the environment. In this lesson we'll look at where to declare variables, what facts are, how register and set_fact work, and who wins when one variable is set in several places.

Where to declare variables

Ansible reads variables from many places. Here are the main ones.

vars: right in the play. The simplest way for local values:

- name: Deploy app
  hosts: webservers
  vars:
    app_port: 8080
    app_user: deploy
  tasks:
    - name: start service on port {{ app_port }}
      ...

vars_files:, an external YAML file. Handy when there are many variables, or you want to reuse them across several plays:

vars_files:
  - vars/common.yml
  - vars/prod.yml

group_vars/ and host_vars/. Directories next to the inventory (or the playbook). Ansible loads them automatically by group or host name.

Layout:

inventory/
  hosts.ini
  group_vars/
    all.yml          # for every host
    webservers.yml   # for the webservers group only
  host_vars/
    web01.yml        # for host web01 only

The inventory directly. You can write variables right in hosts.ini or hosts.yml, but it reads worse and only works for simple cases:

[webservers]
web01 ansible_user=deploy app_port=8080
web02 ansible_user=deploy app_port=8080

[webservers:vars]
nginx_worker_processes=4

defaults/main.yml and vars/main.yml in a role. We covered this in detail in the roles lesson. In short: defaults has the lowest precedence (easy to override from outside), vars has high precedence (the role's own internal constants).

-e / --extra-vars on the command line. The highest precedence, always wins. Handy for one-off overrides and CI scripts:

ansible-playbook deploy.yml -e "app_version=2.1.0 env=prod"
# or from a file
ansible-playbook deploy.yml -e "@overrides.yml"

set_fact. Sets a variable right during the play's run. More on this below.

register. Saves a task's result into a variable. Also below.

Syntax: {{ var }} and YAML quoting

Ansible uses Jinja2 for variable substitution:

- name: create dir
  file:
    path: /opt/{{ app_name }}/data
    state: directory

If a value starts with {{, YAML mistakes the curly braces for the start of a dictionary, and the playbook fails before it even runs. Wrap the whole value in quotes:

# BAD: YAML parsing error
dest: {{ app_dir }}/config.yml

# GOOD
dest: "{{ app_dir }}/config.yml"

ansible-core 2.19 prints this in that case:

[ERROR]: YAML parsing failed: This may be an issue with missing quotes around a template block.

The rule is simple: if a string starts with {{, quote it.

Facts: what Ansible knows about the host

Facts are variables that Ansible gathers about a host before running tasks. The setup module is responsible for this. It runs automatically at the start of every play, showing up in the output as the Gathering Facts task.

The collected data lives in the ansible_facts dictionary. Commonly used ones:

VariableWhat it holds
ansible_facts['os_family']OS family: RedHat, Debian, ...
ansible_facts['distribution']The specific distribution: Ubuntu, Rocky
ansible_facts['default_ipv4']['address']IP of the main interface
ansible_facts['memtotal_mb']Total RAM in megabytes
ansible_facts['hostname']The host's hostname

Many facts have short synonyms with the ansible_ prefix: ansible_os_family, ansible_distribution, ansible_default_ipv4.address, and so on. The docs call this the legacy access method, and you can turn off this substitution with the INJECT_FACTS_AS_VARS setting. Writing ansible_facts['...'] is more reliable.

See every fact for a host with one command:

ansible web01 -m setup
# or only the ones you need:
ansible web01 -m setup -a "filter=ansible_mem*"

If you don't need facts (saving time, or there's no Python on the host), turn them off:

- name: Quick ping
  hosts: all
  gather_facts: no
  tasks:
    - ping:

This is the same case we covered in the lesson about hosts without Python.

An example of using facts in a task:

- name: install Apache on Debian-based systems
  apt:
    name: apache2
    state: present
  when: ansible_facts['os_family'] == "Debian"

register: capture a task's result

register saves the full result of a task into a variable. You can then use it in later tasks through when, debug, or templates.

What ends up in a registered variable:

Example:

- name: check if app is running
  command: systemctl is-active myapp
  register: app_status
  ignore_errors: yes

- name: show status
  debug:
    msg: "App status: {{ app_status.stdout }}"

- name: start app if stopped
  systemd:
    name: myapp
    state: started
  when: app_status.rc != 0

ignore_errors: yes is needed here: systemctl is-active returns a nonzero code (usually 3) if the service isn't active. Without this option the task would fail before we get a chance to check the status.

The command module has no idea whether it changed anything, and always reports changed. For a task that only reads, add changed_when: false, or a repeat run will never show changed=0.

set_fact: set a variable on the fly

set_fact computes and sets a variable right during the play's run. The variable is available in every later task for that host.

- name: set deploy path based on environment
  set_fact:
    deploy_path: "/opt/{{ app_name }}/{{ env }}"
    config_path: "/etc/{{ app_name }}/{{ env }}.conf"

- name: create deploy dir
  file:
    path: "{{ deploy_path }}"
    state: directory

By precedence, set_fact ranks high: above group_vars, play vars:, and role vars/main.yml, but below -e. If the play's vars: sets app_version: unknown, and then set_fact sets app_version: 1.4.0, the rest of the play sees 1.4.0.

An interview detail: set_fact with cacheable: true creates two copies of the variable. One is the regular copy, with set_fact's high precedence, and it's the one that works for the rest of the current run. The second goes into the fact cache and has facts' low precedence (below play vars). The second copy only matters if fact caching is enabled and the variable is read in later runs.

Variable precedence

When the same variable is defined in several places, Ansible takes the value from the source with the highest precedence and ignores the rest. There's no merging by default: one source wins.

(The hash_behaviour=merge option in ansible.cfg changes this for dictionaries, but the docs don't recommend it. Don't use it in new projects.)

A simplified ladder from lowest to highest:

precedence role defaults/main.yml LOWEST inventory → group_vars/ → host_vars/ vars: / vars_files: in the play role vars/main.yml set_fact / register -e / --extra-vars HIGHEST precedence, wins WINNER

The practical rule: -e always wins, role defaults always loses.

The diagram above is simplified. Within the second tier the sources also have their own order: host_vars beats group_vars, and group_vars next to the playbook beats group_vars from the inventory. The official list has 22 items. The first one (command-line options like -u my_user) isn't counted as a variable, so there are 21 variable tiers: from role defaults to extra vars.

Official list: Variable precedence: Where should I put a variable?

How to find out what a host sees without a playbook: ansible <host> -m debug -a var=<name> shows the value from the inventory (group_vars, host_vars). If the value in the playbook is different, a source higher up overrode it: vars:, vars_files:, a role, or set_fact.

Common mistakes

What you'll need for the tasks

Both labs run in a container with ansible-core 2.19.14, and Ansible manages the same machine it runs on (ansible_connection=local). Files live in the lab's work/ folder: edit them with your own editor, run them through bash setup/run.sh.

In the next lesson we'll cover running safely in production: --check, --diff, tags, and --limit.

Done reading?

Mark the lesson as read to move your track progress forward.

Go through it with an AI agent

The agent explains anything unclear in plain words, gives you 2 labs for this lesson and drills you on interview questions. $49 one-time, lifetime access.

Get access for $49

Tell the agent: use MCP izi, give me lesson ansible-basics-08-variables-precedence