Variables, facts and Jinja2 templating

Variable precedence, defaults and vars files, magic variables, gathered and custom facts, the template module, and Jinja2 filters, tests and conditionals.

Where a variable comes from

# precedence, lowest to highest (the ones that matter day to day)
#  1. role defaults        roles/x/defaults/main.yml     <- easiest to override
#  2. inventory group_vars/all
#  3. inventory group_vars/<group>
#  4. inventory host_vars/<host>
#  5. play vars
#  6. set_fact / registered
#  7. --extra-vars on the command line                <- always wins

# roles/web/defaults/main.yml
web_port: 8080
web_workers: "{{ ansible_processor_vcpus }}"

# inventory/group_vars/web.yml
web_port: 80

# inventory/host_vars/web1.example.com.yml
web_workers: 4

# group_vars/all/vault.yml is encrypted; group_vars/all/vars.yml holds the rest
app_db_password: "{{ vault_app_db_password }}"
  • Put anything an operator may reasonably want to change in defaults/main.yml. Values in vars/ are meant to be internal to the role.
  • --extra-vars beats everything, which makes it the right tool for a one-off override and a bad place for routine configuration.
  • Never put a secret in group_vars unencrypted. Keep a vault.yml next to a plain vars.yml that only references it.
  • A variable defined in two group files for the same host follows group order, which is a fragile thing to rely on — prefer host_vars.

Facts and magic variables

- name: Show the useful truth
  ansible.builtin.debug:
    msg: >-
      {{ inventory_hostname }} runs {{ ansible_distribution }}
      {{ ansible_distribution_major_version }} with
      {{ ansible_memtotal_mb }} MB RAM and
      {{ ansible_processor_vcpus }} vCPU

- name: Custom fact from a local file on the target
  ansible.builtin.set_fact:
    role: "{{ lookup('file', '/etc/app/role') }}"

- name: Facts delivered by the inventory instead of gathered
  ansible.builtin.debug:
    msg: "{{ ansible_host }} is in {{ datacenter | default('unknown') }}"

# magic variables worth knowing
# inventory_hostname        the name as written in the inventory
# inventory_hostname_short  the part before the first dot
# ansible_host              the address actually connected to
# group_names               groups the host belongs to
# hostvars[other_host].x    another host's variable
# ansible_facts             everything gathered
# ansible_check_mode        true when running with --check
Fact sourceCostUse when
gather_facts: trueAn SSH round trip per hostYou need distribution or network facts
gather_subsetLess data, less timeYou only need a few fact families
set_factFree, kept for the runA computed value used later
Custom facts in /etc/ansible/facts.dOne readA value the host already knows
Inventory variablesFreeAnything you can declare up front

Set gather_facts: false on plays that only touch the filesystem or restart a service. On a fleet of hundreds of hosts, skipping fact gathering removes minutes from every run.

Templating with Jinja2

# roles/web/templates/app.env.j2
APP_ENV={{ app_env }}
APP_PORT={{ web_port }}
APP_WORKERS={{ web_workers }}
# a value that must always be quoted
APP_NAME="{{ app_name }}"
{% if app_debug %}
APP_DEBUG=true
LOG_LEVEL=debug
{% else %}
APP_DEBUG=false
LOG_LEVEL=info
{% endif %}
# build a list from a dictionary, deterministically sorted
{% for name, value in app_options | dictsort %}
OPT_{{ name | upper }}={{ value }}
{% endfor %}
SENTRY_DSN={{ sentry_dsn | default('') }}
- name: Render the environment file and validate it
  ansible.builtin.template:
    src: app.env.j2
    dest: /etc/app/app.env
    owner: app
    group: app
    mode: "0640"
    validate: /usr/local/bin/check-env %s
  notify: Restart app

- name: Demonstrate the filters you will use most
  ansible.builtin.debug:
    msg:
      - "{{ packages | join(', ') }}"
      - "{{ host_list | map('lower') | list }}"
      - "{{ config | to_json }}"
      - "{{ path | basename }}"
      - "{{ port | int + 1 }}"
      - "{{ not_found | default('fallback') }}"
      - "{{ item is defined and item is not none }}"
⚠️
template writes a file whose content changes only when the rendered output changes, which is what makes notify useful. copy with inline content always reports changed if the content differs, so prefer template for anything derived from variables.

FAQ

Why is my variable not defined inside a role?
Check the precedence order and whether it is declared in defaults/ at all. A variable used in a task before it is set, or spelled differently in two files, produces the classic undefined-variable error.
When should I use <code>set_fact</code> instead of a variable?
When the value is computed during the run from other facts or a previous task result. Anything static belongs in defaults or inventory so it can be reviewed and overridden.

Conditionals, loops and handlers in depth Roles, Vault and idempotency

Last refreshed 2026-09-18.