Jinja2 Foundations
- What Jinja2 is
- Why it matters
- Variable output
- Conditionals
- Loops
- Filters
- Important difference: YAML vs Jinja2
- Example template
- Common mistakes
- Good habits
- Ansible magic variables
- Jinja2 tests (is defined, is none…)
- Key Ansible filters
- {% set %} template variables
- namespace() — loop-scope variables
- map() filter
- to_json / to_yaml
- Whitespace control
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.
{% 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:
lower/upper— change casedefault('fallback')— use fallback if variable is undefined or emptyjoin(', ')— join a list into a single stringint/float— convert typebasename— get filename from a pathtrim— strip leading and trailing whitespace
Important difference: YAML vs Jinja2
- YAML defines data structure — what the variables are and what they contain
- Jinja2 renders dynamic text — turns variables into output
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
- Confusing YAML syntax with Jinja2 syntax inside the same file
- Forgetting
{% endif %}or{% endfor %} - Using a variable that was never defined — use
default()as a guard - Unexpected whitespace from control blocks — use
{%- -%}to strip whitespace if needed - Quoting issues when Jinja2 expressions appear inside YAML string values
Good habits
- Keep template logic simple — put complex decisions in variables or tasks
- Use
default()defensively to avoid undefined variable errors - Inspect rendered output with
--diffbefore applying - Name template files clearly with a
.j2extension
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.
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 %}
| Test | True when… |
|---|---|
is defined | Variable exists (not undefined) |
is undefined | Variable does not exist |
is none | Value is explicitly null |
is string | Value is a string |
is number | Value is int or float |
is iterable | Value can be iterated (list, dict, string) |
is mapping | Value is a dict |
is sequence | Value 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.