Skip to main content
RunBook Academy

AnsibleXVII · Templates and Jinja2Templates and Jinja2

The filters you will actually use

Intermediate⏱ ~20 minansible-playbook

What you'll learn

  • Apply default(), default(x, true) and default(omit) to the right cases
  • Use combine, ternary and to_nice_yaml to generate structured configuration
  • Recognise when a filter chain has become unreadable and what to do instead
  • Explain why omit is different from an empty string or null

Prerequisites

Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11

Not yet marked complete on this device.

Ansible ships hundreds of filters. A production role usually needs seven, and the discipline is in stopping there. This lesson is the seven, with the operational reason for each and an explicit argument about where to stop.

The default family

Three distinct behaviours, and confusing them is the most common template bug in this course.

Read-only / Safethe empty-string case, executed
$ ansible-playbook -i inv.ini undef.yml
TASK [default on a defined-but-empty string] ***********************************
ok: [localhost] => {
  "msg": "plain=[] boolean=[FALLBACK]"
}

default('value') substitutes only when the variable is undefined. A variable that exists and holds an empty string, a zero or an empty list is defined, so the default is not applied — which is why plain=[] above.

default('value', true) substitutes when the variable is undefined or falsy. Empty string, 0, false, empty list and empty dict all trigger the fallback — which is why boolean=[FALLBACK].

That distinction has teeth. A group_vars entry written as

proxy_upstream: ""

by someone who meant “not set here” produces a rendered proxy_pass http://; under plain default(), because the variable is defined. The config file may still parse. The service will not work.

default(omit) is different in kind. It is not a value at all — it is a marker telling Ansible to remove the parameter entirely.

Read-only / Safeomit removes the key
$ ansible-playbook -i inv.ini omit.yml
TASK [What omit resolves to before the module sees it] *************************
ok: [localhost] => {
  "msg": {
      "owner": "root"
  }
}
Configuration changewhere omit belongs
- name: Deploy the application configuration
ansible.builtin.template:
  src: app.conf.j2
  dest: /etc/app/app.conf
  owner: root
  mode: "{{ app_mode | default(omit) }}"
notify: app config changed

mandatory: failing on purpose

Read-only / Safemandatory on an undefined variable
$ ansible-playbook -i inv.ini undef.yml
[ERROR]: Task failed: Finalization of task args for 'ansible.builtin.debug' failed:
Error while resolving value for 'msg': The filter plugin 'ansible.builtin.mandatory'
failed: Mandatory variable 'never_set' not defined.

mandatory is the opposite of default: it turns a missing variable into an immediate, loud, well-located failure. Use it for the values a configuration is meaningless without — a database host, a certificate path, a listen address.

The argument for it is the argument this whole part builds toward: a template that renders something for a missing critical value produces a file that is syntactically valid and operationally wrong. Failing to render is strictly better than rendering the wrong thing. The next lesson is about the cases where this is not obvious.

ternary, combine, to_nice_yaml, from_json, regex_replace

Read-only / Safefour filters, executed
$ ansible-playbook -i inv.ini filters.yml
TASK [ternary and combine] *****************************************************
ok: [localhost] => {
  "msg": "many / {'name': 'billing', 'tier': 'prod'}"
}

TASK [to_json and from_json round trip] ****************************************
ok: [localhost] => {
  "msg": "{\"listen\": 8080, \"name\": \"billing\"} :: {'a': 1}"
}

TASK [regex_replace] ***********************************************************
ok: [localhost] => {
  "msg": "web01"
}

ternarycondition | ternary(if_true, if_false). Reads left to right and stays on one line, which is its whole advantage over an {% if %} for a single value. It stops being an advantage the moment you nest one inside another.

combine — merges dictionaries, right-hand side winning. This is the filter that makes layered configuration possible: a role default dict overlaid with a group dict overlaid with a host dict, producing one structure the template iterates.

Read-only / Safecombine as a layering mechanism
{% set settings = app_defaults | combine(group_settings) | combine(host_settings) %}
{% for key, value in settings | dictsort %}
{{ key }} = {{ value }}
{% endfor %}

Note dictsort. Without it, iteration order follows insertion, which means the rendered file can reorder itself when a variable source changes — a diff full of moved lines, and a changed result on a run where nothing meaningful changed. Sort anything you iterate into a file. It costs one filter and it is the difference between a reviewable diff and a noisy one.

to_nice_yaml — renders a data structure as indented YAML. Useful when the target config format is YAML and you would rather define the structure in group_vars than reproduce it in template syntax.

Read-only / Safeletting the data be the template
scrape_configs:
{{ prometheus_scrape_configs | to_nice_yaml(indent=2) | indent(2, first=True) }}

This is a genuinely good pattern — the structure lives in variables where it can be composed, overridden and validated, and the template is two lines. It is also the pattern most likely to produce a whitespace-mangled file, so --check --diff it before you trust it.

from_json — parses a JSON string into a data structure. Its usual home is not a template but a task: command output registered and parsed, an API response from uri.

regex_replace — string surgery. The example above turns web01.prod.example.com into web01. Real, and worth one caution: if you are using it to extract a field, the data probably wants to be structured in the inventory instead.

Knowledge check

Knowledge check · 4 questions

  1. Q1. group_vars contains proxy_upstream: "" and the template renders {{ proxy_upstream | default("127.0.0.1") }}. What appears in the file?

  2. Q2. What does mode: "{{ app_mode | default(omit) }}" do when app_mode is undefined?

  3. Q3. A template iterates a merged dictionary into a config file. Which practices are worth adopting? Select all that apply.

  4. Q4. Filters in a template execute on the managed node, so a filter needing a Python library requires that library on every managed host.

Passing score: 75%. Answers are checked in this browser.