AnsibleXVII · Templates and Jinja2Templates and Jinja2
The filters you will actually use
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
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.
$ ansible-playbook -i inv.ini undef.ymlTASK [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.
$ ansible-playbook -i inv.ini omit.ymlTASK [What omit resolves to before the module sees it] *************************
ok: [localhost] => {
"msg": {
"owner": "root"
}
}- 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 changedmandatory: failing on purpose
$ 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
$ ansible-playbook -i inv.ini filters.ymlTASK [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"
}ternary — condition | 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.
{% 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.
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
Q1. group_vars contains proxy_upstream: "" and the template renders {{ proxy_upstream | default("127.0.0.1") }}. What appears in the file?
Q2. What does mode: "{{ app_mode | default(omit) }}" do when app_mode is undefined?
Q3. A template iterates a merged dictionary into a config file. Which practices are worth adopting? Select all that apply.
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.