Variables, Facts, and Templating
Variables let a single playbook or role adapt to many hosts, environments, and edge cases without duplicating logic. Ansible combines statically defined variables, dynamically discovered facts, and magic variables (data about the inventory and run itself), then renders everything through the Jinja2 templating engine. This page covers where variables come from, how conflicting definitions resolve through precedence, how facts are gathered and extended, and how to template configuration files reliably.
For where these fit in the broader workflow, see playbooks.md and roles-and-collections.md. For inventory-scoped variables, see inventory.md.
Defining variables
Variables can be set in many places. The most common sources, roughly from broadest scope to most targeted:
---
# Play-level vars (in a playbook)
- name: Configure web tier
hosts: web
vars:
http_port: 80
max_clients: 200
vars_files:
- vars/common.yml # external file of variables
vars_prompt:
- name: db_password
prompt: "Database password"
private: true # do not echo input
tasks:
- name: Show a variable
ansible.builtin.debug:
msg: "Listening on {{ http_port }}"
Other sources you will use constantly:
- Inventory
group_vars/andhost_vars/— files named after a group or host, loaded automatically. See inventory.md. - Role
defaults/main.yml— the lowest-precedence, intended-to-be-overridden values for a role. - Role
vars/main.yml— higher-precedence role constants. set_fact— variables created at runtime from expressions or other facts.- Registered variables — output captured from a task with
register. - Extra vars — passed on the command line with
-e/--extra-vars; these win over everything.
---
- name: Runtime variables
hosts: app
gather_facts: true
tasks:
- name: Register command output
ansible.builtin.command: hostname -f
register: fqdn_result
changed_when: false
- name: Derive a fact from registered output
ansible.builtin.set_fact:
node_fqdn: "{{ fqdn_result.stdout | trim }}"
- name: Use the registered and derived values
ansible.builtin.debug:
msg: "rc={{ fqdn_result.rc }} fqdn={{ node_fqdn }}"
Pass extra vars at invocation time:
ansible-playbook site.yml -e "http_port=8080 env=staging"
ansible-playbook site.yml -e "@vars/overrides.yml" # from a file
Note
Variable names must be valid Python identifiers: letters, digits, and underscores, and they cannot start with a digit. Avoid reserved names and Ansible's own keywords (for example environment, name, tags). Prefix role variables with the role name (nginx_worker_processes) to prevent collisions across roles.
Variable precedence
When the same variable is defined in more than one place, Ansible resolves it using a fixed order of roughly 22 levels. Later (higher) sources override earlier (lower) ones. You rarely need the full list, but you must know the practical ordering to avoid surprises.
| Precedence (low to high) | Source | Typical use |
|---|---|---|
| 1 (lowest) | Role defaults/main.yml |
Sane, overridable defaults |
| 2 | Inventory group_vars/ |
Per-group configuration |
| 3 | Inventory host_vars/ |
Per-host configuration |
| 4 | Play vars and vars_files |
Play-scoped settings |
| 5 | Role vars/main.yml |
Role constants |
| 6 | Block and task vars |
Narrow, local overrides |
| 7 | set_fact and registered vars |
Runtime-derived values |
| 8 (highest) | Extra vars (-e) |
Command-line overrides, always win |
Tip
Put values you expect operators to override in a role's defaults/ directory, and put values that must not change in vars/. Because vars/ outranks inventory group_vars/host_vars, constants placed there cannot be accidentally overridden by inventory.
The table above is a shortlist. The complete ordering distinguishes several finer levels (for example inventory file vars versus group_vars, and playbook group_vars versus inventory group_vars). See the official precedence reference linked under References for the authoritative full list.
Warning
Extra vars (-e) override everything, including set_fact. This makes them powerful for one-off runs but dangerous in automation: a stray -e can silently mask carefully computed values. Reserve extra vars for deliberate overrides.
Accessing variables and subelements
Reference a variable by wrapping its name in double curly braces. Quote any value that begins with {{ so YAML does not misparse it as a dictionary.
---
- name: Access variables
hosts: localhost
vars:
app:
name: billing
ports: [8080, 8443]
tasks:
- name: Dotted access
ansible.builtin.debug:
msg: "{{ app.name }} on {{ app.ports[0] }}"
- name: Bracket access (needed for keys with dashes or dynamic keys)
ansible.builtin.debug:
msg: "{{ app['name'] }}"
Use bracket notation when a key contains characters that are not valid in an identifier (hyphens, dots) or when the key itself is stored in a variable. Guard optionally defined variables with the default filter so a missing value does not fail the run:
---
- name: Provide fallbacks
ansible.builtin.debug:
msg: "{{ log_level | default('info') }}"
The default filter also accepts a second argument, default(omit), which removes the parameter entirely when the source variable is undefined — useful for optional module arguments.
Facts
Facts are pieces of system information Ansible discovers about each managed host: OS family, network interfaces, memory, mounted filesystems, and much more. By default a play begins with an implicit fact-gathering step that runs the ansible.builtin.setup module against every host.
---
- name: Use gathered facts
hosts: all
gather_facts: true # default; set false to skip
tasks:
- name: Show OS details
ansible.builtin.debug:
msg: >-
{{ ansible_facts['distribution'] }}
{{ ansible_facts['distribution_version'] }}
({{ ansible_facts['os_family'] }})
- name: Show primary IPv4 address
ansible.builtin.debug:
msg: "{{ ansible_facts['default_ipv4']['address'] }}"
Facts are exposed two ways. The modern namespace is ansible_facts (for example ansible_facts['os_family']). The legacy form promotes each fact to a top-level ansible_-prefixed variable (for example ansible_os_family, ansible_default_ipv4.address). Both refer to the same data; the ansible_facts dictionary is preferred in new code.
Gathering facts on every host adds latency. Disable it when a play does not need facts, or gather them explicitly later:
---
- name: Skip automatic gathering for speed
hosts: all
gather_facts: false
tasks:
- name: Gather only when needed
ansible.builtin.gather_facts:
tags: [facts]
Note
You can also collect a subset of facts by setting gather_subset (for example gather_subset: ['network', '!hardware']) on the play or on the ansible.builtin.setup module, which reduces gathering time on large fleets.
Custom facts
Beyond built-in facts, you can supply your own. Local facts are files placed in /etc/ansible/facts.d/ on the managed host, ending in .fact. Static files use INI or JSON format; executable files must emit JSON on stdout. Their contents appear under ansible_facts['ansible_local'].
# /etc/ansible/facts.d/app.fact
[deployment]
tier=web
release=2024.11
---
- name: Read a local fact
ansible.builtin.debug:
msg: "{{ ansible_facts['ansible_local']['app']['deployment']['tier'] }}"
Runtime facts are created during a play with ansible.builtin.set_fact. By default they live only for the current run; add cacheable: true to persist them to the configured fact cache so later plays can read them.
---
- name: Set a cacheable runtime fact
ansible.builtin.set_fact:
deploy_stamp: "{{ '%Y%m%dT%H%M%S' | strftime }}"
cacheable: true
Magic variables
Magic variables are provided automatically by Ansible and describe the inventory, the current host, and the run. They are read-only and cannot be set with set_fact.
| Variable | Meaning |
|---|---|
hostvars |
Dictionary of every host's variables and facts, keyed by hostname |
groups |
Dictionary mapping each group name to its list of hosts |
group_names |
List of groups the current host belongs to |
inventory_hostname |
Name of the current host as defined in inventory |
inventory_hostname_short |
The hostname up to the first dot |
ansible_play_hosts |
Hosts still active in the current play (post-failure) |
play_hosts |
Legacy alias for the active-hosts list |
ansible_playbook_python |
Path to the Python interpreter running ansible-playbook |
hostvars is the key to cross-host data. Because facts are gathered per host, one host can read another's facts — provided that host has been gathered earlier in the run:
---
- name: Build a backend pool from peer facts
hosts: load_balancers
tasks:
- name: List app-server IPs
ansible.builtin.debug:
msg: >-
{{ groups['app']
| map('extract', hostvars, ['ansible_facts', 'default_ipv4', 'address'])
| list }}
Jinja2 templating
Ansible uses Jinja2 for all variable interpolation. There are three delimiters:
{{ ... }}— expressions that render a value.{% ... %}— statements such as loops and conditionals.{# ... #}— comments that are removed from output.
Filters transform values with a pipe. Common filters include default, to_json, to_nice_yaml, join, map, select/reject, unique, combine, regex_replace, and b64encode. Tests return booleans and are used with is, such as is defined, is match, and is version.
---
- name: Filter examples
hosts: localhost
vars:
packages: [nginx, curl, nginx, git]
base_config: { timeout: 30, retries: 3 }
override: { retries: 5 }
tasks:
- name: Deduplicate and join
ansible.builtin.debug:
msg: "{{ packages | unique | join(', ') }}"
- name: Merge two dictionaries (override wins)
ansible.builtin.debug:
msg: "{{ base_config | combine(override) }}"
- name: Select matching hosts and encode a secret
ansible.builtin.debug:
msg: >-
{{ groups['all'] | select('match', '^web') | list }}
token={{ 'p@ss' | b64encode }}
- name: Guard on definition and pattern
ansible.builtin.debug:
msg: "release looks valid"
when:
- app_release is defined
- app_release is match('^[0-9]{4}\\.[0-9]{2}$')
Important
Filters and tests run on the control node, not the managed host. They operate on already-gathered data, so a filter cannot query a remote system by itself — use a module or lookup for that.
The template module
ansible.builtin.template renders a Jinja2 .j2 file locally and copies the result to the target. Templates have full access to variables, facts, and magic variables.
---
- name: Render nginx config
ansible.builtin.template:
src: templates/nginx.conf.j2
dest: /etc/nginx/nginx.conf
owner: root
group: root
mode: "0644"
validate: "nginx -t -c %s"
notify: Reload nginx
A short template that uses a loop and a conditional:
# {{ ansible_managed }}
worker_processes {{ nginx_worker_processes | default('auto') }};
upstream backend {
{% for host in groups['app'] %}
server {{ hostvars[host]['ansible_facts']['default_ipv4']['address'] }}:8080;
{% endfor %}
}
server {
listen {{ http_port | default(80) }};
{% if enable_tls | default(false) %}
listen 443 ssl;
ssl_certificate /etc/ssl/certs/site.pem;
ssl_certificate_key /etc/ssl/private/site.key;
{% endif %}
}
Lookups run on the control node and pull external data into a play. Useful ones include file (read a local file), env (read an environment variable), and template (render a template into a variable):
---
- name: Lookups
ansible.builtin.debug:
msg: >-
pubkey={{ lookup('ansible.builtin.file', '~/.ssh/id_ed25519.pub') }}
home={{ lookup('ansible.builtin.env', 'HOME') }}
Tip
Keep secrets out of templates and plain variable files. Store sensitive values with Ansible Vault and reference them like any other variable — see vault-and-secrets.md.