NovuSpark
All articles
AnsibleFebruary 13, 2026 · NovuSpark Team

Ansible Variables, Facts, and Templates (Jinja2)

This is the third post in our Ansible fundamentals series, building on inventory and playbooks and roles.

Every Ansible user eventually hits the same confusing moment: a variable has a value they didn't expect, set somewhere they didn't think to look. Ansible pulls variables from a genuinely large number of sources, and understanding the precedence between them is the difference between confidently tracing a value to its source and guessing.

Where variables actually come from

Roughly in order from lowest to highest precedence (Ansible's real list has more granularity than this, but these are the sources that account for the vast majority of real confusion):

  1. Role defaults/ (the lowest precedence — meant to be overridden)
  2. Inventory group and host variables
  3. Playbook vars: blocks
  4. Role vars/main.yml (distinct from defaults/ — higher precedence, not meant to be casually overridden)
  5. Task-level vars:
  6. Extra vars passed via -e on the command line (the highest precedence — always wins)
6. -e extra vars (command line)5. task-level vars:4. role vars/main.yml3. playbook vars: block2. inventory group_vars / host_varswins
Fig. 1 — variable precedence, highest at top — role defaults/ (not shown, lowest of all) always loses to every one of these
ansible-playbook -i inventory.ini site.yml -e "nginx_worker_connections=8192"

That single -e flag overrides every other source of nginx_worker_connections, no matter where else it was set — which is exactly why -e is the right tool for a genuinely one-off override, and the wrong tool for anything meant to be a durable, reviewable configuration decision. A value that matters for a specific environment belongs in inventory group variables, not in a flag someone has to remember to type correctly every time.

Inventory variables in practice

# inventory.ini
[web]
web1.internal
web2.internal
 
[web:vars]
nginx_worker_connections=2048
 
[production:children]
web
 
[production:vars]
environment=production
monitoring_enabled=true

[web:vars] applies to every host in the web group; [production:vars] applies to the whole production group-of-groups. This is usually the right layer for "this value differs by environment or by role" — it's visible, versioned alongside the rest of the inventory, and doesn't require touching the playbook or role itself.

For anything with more structure than flat key-value pairs, YAML inventory files (or group_vars/ and host_vars/ directories) are the more common real-world pattern:

inventory/
  hosts.ini
  group_vars/
    web.yml
    production.yml
  host_vars/
    web1.internal.yml
# group_vars/web.yml
---
nginx_worker_connections: 2048
nginx_client_max_body_size: "5m"
app_pool_size: 4

Ansible loads group_vars/<groupname>.yml and host_vars/<hostname>.yml automatically, purely by filename convention — no explicit include required. This convention-over-configuration approach is deliberate: it means every host or group can carry its own variables without the playbook needing to know, in advance, which groups exist or what they're called.

Facts: variables Ansible discovers, not ones you set

Before running any tasks, Ansible gathers facts — information about each target host, collected automatically at the start of a play.

- name: Show some facts
  hosts: web
  tasks:
    - name: Print OS family
      ansible.builtin.debug:
        msg: "{{ ansible_facts['os_family'] }} on {{ ansible_facts['distribution'] }}"
ok: [web1.internal] => {
    "msg": "Debian on Ubuntu"
}

Facts are what make a single playbook safely portable across a mixed fleet:

- name: Install web server package
  ansible.builtin.package:
    name: "{{ 'httpd' if ansible_facts['os_family'] == 'RedHat' else 'nginx' }}"
    state: present

One task, correct on both Ubuntu and RHEL hosts, because the fact-gathering step already told Ansible which distribution family it's talking to. Facts cover far more than the OS: memory size, CPU count, mounted filesystems, network interfaces and their IP addresses, environment variables, and (with the right collections installed) cloud-provider-specific metadata like an EC2 instance's tags or availability zone. A task that needs to size something proportionally to available memory, for instance, can reference ansible_facts['memtotal_mb'] directly rather than hardcoding a value that would be wrong on a differently-sized host.

Fact gathering does add a small amount of time to every play — for a large fleet where you know facts aren't needed, gather_facts: false at the play level skips it, but that's an optimization to reach for once it's actually a measured bottleneck, not a default.

Jinja2 templates: variables and facts, rendered into real files

We used ansible.builtin.template in the previous post without fully explaining it — this is where it earns its keep. A .j2 file is a plain-text file (a config file, in the common case) with Jinja2 expressions embedded directly in it.

{# templates/app.conf.j2 #}
server {
    listen {{ app_port | default(8080) }};
    server_name {{ ansible_facts['hostname'] }};
 
    {% if environment == "production" %}
    access_log /var/log/app/access.log combined;
    {% else %}
    access_log /var/log/app/access.log;
    {% endif %}
 
    upstream backend {
        {% for host in groups['app_servers'] %}
        server {{ hostvars[host]['ansible_facts']['default_ipv4']['address'] }}:{{ app_port | default(8080) }};
        {% endfor %}
    }
}

This one template demonstrates most of what Jinja2 templating is actually used for in Ansible:

  • {{ variable | default(value) }} — a filter providing a fallback if the variable isn't set, avoiding a hard failure for genuinely optional configuration.
  • {% if %} / {% else %} — conditional blocks, commonly used exactly like this: different config for production vs. everything else.
  • {% for %} — looping, here over groups['app_servers'] (every host in that inventory group) to generate a load-balancer upstream block automatically, without hardcoding a single hostname.
  • hostvars — a way to reach into another host's facts and variables from within the current host's template. This is how one server's config file can correctly reference the current IP addresses of several other servers, generated fresh on every run.

Checking a rendered template before it's deployed

Because a template renders differently per host (different facts, different group membership), it's worth being able to preview the actual rendered output before trusting it against a real fleet:

ansible web1.internal -i inventory.ini -m template \
  -a "src=templates/app.conf.j2 dest=/tmp/app.conf.preview" \
  --check --diff

Combined with --check --diff (introduced in the first post in this series), this renders the template exactly as it would be deployed and shows the resulting diff against whatever's currently on disk — without actually writing the file — which is a genuinely fast way to catch a Jinja2 typo or a missing variable before it reaches a real host.

Registered variables: capturing a task's own result

Beyond variables set through inventory or vars:, a task's own output can be captured and reused by later tasks in the same play via register — genuinely useful for making one task's result available to a conditional or template later in the same run:

- name: Check if config file already exists
  ansible.builtin.stat:
    path: /etc/app/app.conf
  register: config_check
 
- name: Deploy default config only if none exists
  ansible.builtin.template:
    src: default.conf.j2
    dest: /etc/app/app.conf
  when: not config_check.stat.exists

register: config_check captures the stat module's full result into a variable, later referenced in a when: condition on a subsequent task — this is the mechanism behind a large share of real conditional logic in Ansible playbooks: check something's current state first, then decide the next task's behavior based on what was actually found, rather than assuming a fixed sequence is always correct regardless of a host's starting state.

Filters: transforming a variable's value inline

Jinja2 filters (introduced briefly with default() earlier in this post) extend well beyond providing fallback values — genuinely useful for reshaping data directly within a template or task, without a separate preprocessing step:

{{ ansible_facts['distribution'] | lower }}
{{ database_password | to_json }}
{{ allowed_ips | join(', ') }}
{{ my_list | unique | sort }}

lower, join, unique, sort, and dozens of others are all standard Jinja2 filters, chainable with the pipe syntax — a genuinely powerful way to transform a fact or variable's value directly at the point it's used, rather than writing a separate task purely to reshape data before it's referenced elsewhere.

What to actually remember from this post

  • Variable precedence has a real, learnable order-e always wins, role defaults always lose; everything else sits somewhere in between based on how specifically it's scoped.
  • group_vars/ and host_vars/ are the usual right place for environment- and host-specific values, not scattered -e flags or inline vars: blocks.
  • Facts are discovered, not declared — they're what lets one playbook behave correctly across a genuinely mixed fleet, referencing anything from OS family to available memory to cloud-provider metadata.
  • Jinja2 templates turn variables and facts into real config files — and hostvars is what lets one host's template reference another host's current state.
  • --check --diff on a template task previews the rendered file before it's ever written to a real host.

Next in the series: Ansible Vault: Managing Secrets Safely, where we cover what happens when one of these variables is a password.

Ready when you are

Want training built around your team's real work?

Tell us about your team and what you're trying to solve — we'll recommend a program that fits.