Jinja2 Foundations

Page 07 — Template language used by Ansible. Variables, conditionals, loops, and filters.

What Jinja2 is

Jinja2 is a template language. It lets you build text files dynamically using variables and logic. Ansible uses it extensively in the template module to render config files before deploying them to hosts.

A Jinja2 template is a plain text file (typically ending in .j2) containing a mix of static text and Jinja2 expressions.

Why it matters

Instead of writing 10 nearly identical config files by hand, you write one template and feed in different variables for each host or environment. The template renders the correct file for each target.

Variable output

Use double curly braces to output a variable's value:

{{ nginx_port }}
{{ inventory_hostname }}
{{ ansible_default_ipv4.address }}

Anything inside {{ }} is evaluated and replaced with its value when the template renders.

Conditionals

{% if enable_tls %}
listen 443 ssl;
{% else %}
listen 80;
{% endif %}

What this means: if enable_tls is true, use the HTTPS listener; otherwise use plain HTTP.

Remember: Every {% if %} must have a matching {% endif %}. Missing it is one of the most common Jinja2 errors.

Loops

{% for server in ntp_servers %}
server {{ server }} iburst
{% endfor %}

This renders one server ... iburst line for each item in the ntp_servers list.

Filters

Filters transform values. Apply them with a pipe character |:

{{ username | lower }}
{{ value | default('none') }}
{{ items | join(', ') }}
{{ path | basename }}
{{ number | int }}

Common filters:

Important difference: YAML vs Jinja2

People often confuse the two because Ansible uses both in the same project. YAML is for playbooks, inventory, and variable files. Jinja2 is for template files and inside quoted strings in tasks.

Example template

An nginx config template (nginx.conf.j2):

user nginx;
worker_processes auto;

events {
  worker_connections 1024;
}

http {
  server {
    listen {{ nginx_port }};
    server_name {{ server_name }};

    {% if enable_tls %}
    ssl_certificate     /etc/ssl/certs/{{ server_name }}.crt;
    ssl_certificate_key /etc/ssl/private/{{ server_name }}.key;
    {% endif %}
  }
}

Deployed with an Ansible task:

- name: Deploy nginx config
  ansible.builtin.template:
    src: nginx.conf.j2
    dest: /etc/nginx/nginx.conf
  notify: Restart nginx

Common mistakes

Good habits

Ansible magic variables

Ansible automatically provides a set of special variables in every template and task. You do not define them — Ansible populates them from the inventory and runtime context.

Variable What it contains
inventory_hostname The name of the current host as written in your inventory
ansible_default_ipv4.address The primary IPv4 address of the current host
groups['groupname'] List of all hosts in the named inventory group
hostvars['hostname'] All Ansible facts and variables for any other host

Example: render a config line for every host in a group, using their IP addresses:

# In a Jinja2 template (.j2 file)
# Builds an allow-list of all backend server IPs

{% for host in groups['backends'] %}
allow {{ hostvars[host]['ansible_default_ipv4']['address'] }};
{% endfor %}

This loops over every host in the backends inventory group and writes an allow line per host using that host's primary IP. Ansible collects the IPs automatically during the play via fact-gathering.

Tip: Fact gathering must be enabled (the default) for ansible_default_ipv4 to be populated. If you turned off gathering with gather_facts: false, use hostvars[host]['ansible_host'] instead (the inventory address).

Jinja2 tests (is defined, is none…)

Jinja2 tests are used with the is keyword to check a value's state or type. They are cleaner and more explicit than relying on truthiness checks.

# is defined — the most useful test in Ansible templates
{% if db_password is defined %}
password={{ db_password }}
{% endif %}

# is none — check explicitly for None/null (different from undefined!)
{% if proxy_url is none %}
# no proxy configured
{% else %}
proxy={{ proxy_url }}
{% endif %}

# is string / is number / is iterable
{% if listen_ports is iterable and listen_ports is not string %}
{% for port in listen_ports %}
listen {{ port }};
{% endfor %}
{% else %}
listen {{ listen_ports }};
{% endif %}

# is mapping (dict check)
{% if extra_headers is mapping %}
{% for key, value in extra_headers.items() %}
add_header {{ key }} "{{ value }}";
{% endfor %}
{% endif %}
TestTrue when…
is definedVariable exists (not undefined)
is undefinedVariable does not exist
is noneValue is explicitly null
is stringValue is a string
is numberValue is int or float
is iterableValue can be iterated (list, dict, string)
is mappingValue is a dict
is sequenceValue is a list or string

Key Ansible filters

Ansible adds many filters on top of standard Jinja2. These are the ones you will see most often in real infrastructure templates.

ternary — inline if/else

# value | ternary(true_val, false_val)
listen_ssl: "{{ (enable_tls | bool) | ternary('443', '80') }}"
max_workers: "{{ (env == 'production') | ternary(16, 4) }}"

regex_replace — transform strings

safe_hostname: "{{ inventory_hostname | regex_replace('\\.', '_') }}"
# web01.internal.example.com → web01_internal_example_com

quote — shell safety

# Always quote variables used in shell commands
- name: Run script with user input
  ansible.builtin.shell: "/usr/bin/process {{ user_input | quote }}"

selectattr / rejectattr — filter a list of dicts

# users is a list of dicts with 'name', 'state', 'groups' keys
# Get only active users
active_users: "{{ users | selectattr('state', 'equalto', 'present') | list }}"

# Get users who are in the admin group
admins: "{{ users | selectattr('groups', 'contains', 'wheel') | list }}"

# Exclude disabled users
enabled: "{{ users | rejectattr('state', 'equalto', 'absent') | list }}"

dict2items / items2dict — convert between dict and list

# dict2items — iterate a dict as a list of {key, value} pairs
env_vars:
  APP_ENV: production
  LOG_LEVEL: warn
  DB_HOST: db01.internal

# In template:
{% for item in env_vars | dict2items %}
{{ item.key }}={{ item.value }}
{% endfor %}
# Renders:
# APP_ENV=production
# LOG_LEVEL=warn
# DB_HOST=db01.internal

# items2dict — rebuild a dict from a list
# Useful when group_vars has a list but you need key lookup
all_vars_dict: "{{ all_vars_list | items2dict(key_name='name', value_name='value') }}"

{% set %} template variables

Use {% set %} to create a local variable inside a template. This avoids repeating complex expressions and makes templates easier to read.

# Calculate a value once and reuse it
{% set max_conn = (ansible_memtotal_mb / 4) | int %}
max_connections = {{ max_conn }}
shared_buffers = {{ (max_conn * 8) }}MB

# Build a string conditionally
{% set ssl_dir = '/etc/pki/tls' if ansible_os_family == 'RedHat' else '/etc/ssl' %}
ssl_certificate {{ ssl_dir }}/certs/{{ inventory_hostname }}.crt;
ssl_certificate_key {{ ssl_dir }}/private/{{ inventory_hostname }}.key;

# Collect filtered items into a variable
{% set active_vhosts = vhosts | selectattr('enabled', 'equalto', true) | list %}
# Active vhosts: {{ active_vhosts | length }}
{% for vhost in active_vhosts %}
server_name {{ vhost.name }};
{% endfor %}

{% set %} variables are scoped to the current template. They do not persist between templates or back to the playbook — use set_fact for that.

namespace() — the loop-scope problem

A variable created with {% set %} inside a {% for %} loop does not leak out. This surprises people constantly — the counter or flag you carefully built inside the loop is gone (or unchanged) on the line after {% endfor %}.

# BROKEN — found_tls stays False outside the loop no matter what
{% set found_tls = False %}
{% for srv in servers %}
  {% if srv.tls %}
    {% set found_tls = True %}   {# only visible inside this loop iteration #}
  {% endif %}
{% endfor %}
tls_in_use = {{ found_tls }}     {# always False — surprise! #}

# FIX — wrap the variable in a namespace object, which IS mutable across scopes
{% set ns = namespace(found_tls=False) %}
{% for srv in servers %}
  {% if srv.tls %}
    {% set ns.found_tls = True %}
  {% endif %}
{% endfor %}
tls_in_use = {{ ns.found_tls }}  {# correctly True when any server has tls #}

Jinja2 gives {% set %} block-local scope. namespace() returns an object whose attributes behave like ordinary mutable variables, so assignment inside a loop is visible outside. Use this whenever you need "did I see X at least once?" or running totals across an iteration.

map() filter — transform every item in a list

map() applies a filter or attribute lookup to every element of a list, returning a new list. Incredibly useful for pulling fields out of a list of dicts or normalising values in one expression.

# Extract one attribute from every item — users is a list of dicts with a 'name' key
user_names: "{{ users | map(attribute='name') | list }}"
# → ['alice', 'bob', 'carol']

# Nested attribute lookup (dotted path)
ips: "{{ groups['web'] | map('extract', hostvars, ['ansible_default_ipv4', 'address']) | list }}"

# Apply another filter to every item — uppercase everything
shouty: "{{ tags | map('upper') | list }}"
# ['prod', 'web'] → ['PROD', 'WEB']

# Combine with join() — common "comma-separated list of names" pattern
{{ users | map(attribute='name') | join(', ') }}
# alice, bob, carol

Without | list at the end you get a Jinja2 generator object — it usually renders fine inside a template but will surprise you in an Ansible set_fact. When in doubt, finish the chain with | list.

to_json / to_yaml — serialise structured data

Ansible ships to_json, to_yaml, and their _pretty variants as Jinja2 filters. They turn a dict/list into the right wire format for a config file, API payload, or debug dump.

# Embed a JSON config fragment in a bigger file
config_json = {{ app_config | to_json }}

# Pretty-print for human-readable config files
{{ app_config | to_nice_json(indent=2) }}
{{ app_config | to_nice_yaml(indent=2) }}

# Quick debug dump of a variable's real structure
- debug:
    msg: "{{ ansible_facts | to_nice_yaml }}"

# Round-trip — reading JSON/YAML back into a variable
{% set parsed = raw_string | from_json %}
{{ parsed.some.nested.value }}

Use to_nice_json / to_nice_yaml when a human will read the output (config files, debug); use to_json / to_yaml when a machine will (API bodies, compact embeds). from_json / from_yaml parse strings back into structured data — useful after shell tasks that return JSON.

Whitespace control — {%- -%} and {{- -}}

By default, Jinja2 preserves every newline and leading-whitespace around control blocks. That is usually fine in config files (the blank lines disappear into comments) but ugly in shell scripts, JSON, or any format that is sensitive to extra lines.

# WITHOUT whitespace control — produces a blank line for every iteration
server_list:
{% for s in servers %}
  - {{ s }}
{% endfor %}

# Output:
# server_list:
#
#   - web01
#
#   - web02
#

# WITH {%- and -%} — trims whitespace on that side of the tag
server_list:
{%- for s in servers %}
  - {{ s }}
{%- endfor %}

# Output:
# server_list:
#   - web01
#   - web02

# Same trick on expressions — strip the surrounding newline
value={{- my_var -}}.suffix
# my_var="abc" → value=abc.suffix   (no whitespace around)

The dash goes on the side whose whitespace you want to eat: {%- strips whitespace (and a newline) before the tag, -%} strips after. Same for {{- -}}. When a rendered file has ugly blank lines between loop items, this is what you reach for.