Most engineers think…
Most engineers first meet Ansible and think "idempotent" just means "it doesn't error on a re-run", and that a Jinja2 template is basically a fancy copy-paste with a few find-and-replace blanks.
Wrong on both — and both misreadings cost you on the job and in the RHCE exam. Idempotency is a strict guarantee: run a task N times and the end state is identical, and Ansible reports "changed" only when it actually altered something. That "changed" flag is real signal you trigger handlers off and audit against. A Jinja2 template isn't find-and-replace — it's a small program: it loops over lists, branches on conditions, and pipes values through filters, all rendered on the control node before a single byte hits the device. Miss either idea and you'll write playbooks that lie about what they changed.
① Jinja2 in Ansible — variables + logic become config
Meet Sneha, an L1 automation engineer at Infosys. She has one job today: push the same baseline config to 40 branch routers — but every branch has a different hostname, management IP and set of VLANs. The slow way is 40 copy-pastes and 40 chances to fat-finger an IP. The Ansible way is to write the config once as a template and let the template module render a unique file per device. The template is the blueprint; the per-host variables are the measurements.
Jinja2 is the engine behind every variable in Ansible. The first and most-used bit of syntax is substitution: anything inside double braces is an expression Jinja2 evaluates and prints. So {{ inventory_hostname }} becomes the host's name and {{ ansible_host }} becomes its IP. The braces say "print the value of this variable here".
Two more pieces of syntax turn a flat template into a small program. Loops — {% for iface in interfaces %} … {% endfor %} — repeat a block once per item in a list. Conditionals — {% if site_type == "hub" %} … {% endif %} — include a block only when a test is true. The rule of thumb: double braces {{ ... }} print a value; percent-braces {% ... %} are control logic and print nothing themselves.
The last core idea is filters. A filter sits after a pipe | and transforms the value. The four you'll reach for daily: | default("1") supplies a fallback so a missing variable doesn't crash the render; | upper upper-cases text; | ansible.utils.ipaddr("netmask") turns a CIDR into a netmask (the ipaddr filter now lives in the ansible.utils collection); and | to_yaml / | to_nice_yaml serialise a structure to YAML. Filters are how you keep templates safe and readable instead of stuffing logic everywhere.
The four pieces of Jinja2, one tap each
Tap each card — these four are 90% of what you'll type in a template, and they're staple RHCE exam questions.
Double braces print a variable's value into the text. So: hostnames, IPs and VLAN ids drop in per host automatically.
Repeat a block once per list item. So: one loop writes ten interface stanzas from a ten-item list — no copy-paste.
Include a block only when a test is true. So: hub sites get OSPF area 0, spokes get a stub area — same template.
Transform a value on the way out: | default, | upper, | ipaddr. So: a missing var falls back instead of crashing the render.
hostname {{ inventory_hostname }}
!
interface Loopback0
ip address {{ loopback_ip }} 255.255.255.255
!
{% if site_type == "hub" %}
banner motd ^HUB SITE - change control applies^
{% endif %}
snmp-server location {{ site_location | default("UNSET") | upper }}# rendered for BR-Mumbai (host_vars: loopback_ip=10.255.0.1, site_type=hub): hostname BR-Mumbai interface Loopback0 ip address 10.255.0.1 255.255.255.255 banner motd ^HUB SITE - change control applies^ snmp-server location MUMBAI-BKC
A Jinja2 template is the Mumbai dabbawala label layout. The layout (name field, area field, route code) is fixed — that's your template text. Each customer's actual name and address are the variables. The printed label for one customer is a rendered config. Change the layout once and every label updates; change one customer's address and only their label changes. You never hand-write 200 labels, and you never hand-write 40 router configs.
Rahul at TCS writes a template line: `description ${JJ(' link_desc | default("uplink") ')}`. For a host where `link_desc` was never defined in host_vars, what gets rendered?
Pause & Predict
Predict: a template references `${JJ(' vlan_id ')}` for one host that has no vlan_id set, and the line has NO | default filter. What happens to the render — and does the device get a half-finished config? Type your guess.
② Building real config — loop interfaces, branch on site type
Now the real thing. Priya at Wipro needs every branch router configured with its interfaces, its VLANs and OSPF — but the interface list differs per site, and hub sites need extra OSPF settings spokes don't. Instead of 40 bespoke files, she puts a host_vars file per router holding a list of interfaces and a site_type, then writes ONE template that loops and branches.
site_type: hub
ospf_process: 1
interfaces:
- name: GigabitEthernet0/1
desc: LAN-users
ip: 192.168.10.1
mask: 255.255.255.0
vlan: 10
- name: GigabitEthernet0/2
desc: LAN-voice
ip: 192.168.20.1
mask: 255.255.255.0
vlan: 20# Ansible auto-loads this for host BR-Mumbai.
# 'interfaces' is a LIST of dicts → the template will {% for %} over it.
# 'site_type: hub' → the template's {% if %} branch fires.Here's the template that consumes it. Watch two things: the {% for iface in interfaces %} loop writes one stanza per interface, and inside it {{ iface.ip }} reaches into the nested data with dot notation. Then a {% if site_type == "hub" %} block adds OSPF settings only hubs need.
hostname {{ inventory_hostname }}
!
{% for iface in interfaces %}
interface {{ iface.name }}
description {{ iface.desc | upper }}
ip address {{ iface.ip }} {{ iface.mask }}
no shutdown
!
{% endfor -%}
router ospf {{ ospf_process }}
{% for iface in interfaces %}
network {{ iface.ip | ansible.utils.ipaddr('network') }} 0.0.0.255 area 0
{% endfor %}
{%- if site_type == "hub" %}
area 0 authentication message-digest
{% endif %}hostname BR-Mumbai interface GigabitEthernet0/1 description LAN-USERS ip address 192.168.10.1 255.255.255.0 no shutdown interface GigabitEthernet0/2 description LAN-VOICE ip address 192.168.20.1 255.255.255.0 no shutdown router ospf 1 network 192.168.10.0 0.0.0.255 area 0 network 192.168.20.0 0.0.0.255 area 0 area 0 authentication message-digest
The little dashes — {%- … -%} — are whitespace control. A dash on the left of a tag trims the newline and spaces before it; on the right, after it. Without them, every {% endfor %} and {% if %} leaves a blank line, and a router config full of stray blank lines is ugly and can break parsing. Useful default to remember: in Ansible, trim_blocks is ON and lstrip_blocks is OFF by default — different from plain Jinja2 — so the newline right after a block tag is already removed, but leading indentation before a tag is not. When loops still leave gaps, reach for the dashes or set #jinja2: lstrip_blocks: True at the top of the template.
Symptom: your `branch.j2` renders correctly but the output is littered with empty lines after every loop iteration, and a network module rejects it or the diff is noisy. Cause: the newline that follows each `{% endfor %}` / `{% if %}` tag is being kept. Fix: add whitespace dashes — `{% endfor -%}` and `{%- if … %}` — to trim the offending side, or put `#jinja2: trim_blocks: True, lstrip_blocks: True` as the first line of the template. Don't 'fix' it by deleting lines in the template body — that breaks readability; control the whitespace at the tag.
Karthik's `branch.j2` loops over a 6-interface list but the rendered file has a blank line after each interface stanza. Which change fixes it most cleanly?
Pause & Predict
Predict: Priya adds a 7th interface to BR-Mumbai's host_vars list but does NOT touch branch.j2. After re-running, how many template edits did she make, and how many interface stanzas now render? Type your guess.
③ Idempotency — Ansible's core promise, and where it breaks
Here is the word that separates a hobbyist from an engineer: idempotency. A task is idempotent if running it once or a hundred times leaves the system in the same end state, and — just as important — it reports "changed" only when it genuinely altered something. The second run of a healthy playbook should be a calm wall of green ok with changed=0. That's not a nice-to-have; it's Ansible's core promise and the foundation of safe automation.
Why does it matter so much? Because the "changed" flag is real signal. You fire handlers off it (reload the service only if the config actually changed), you audit against it (a clean run proves no drift), and you trust it in change windows. If a task lies and says "changed" every time, your handlers fire needlessly and your audits are meaningless.
How do modules achieve it? A native module — template, ansible.builtin.lineinfile, ansible.builtin.user, service — first reads the current state, compares it to the desired state you declared, and acts only if they differ. The template module compares the file it would write against what's already there; identical means ok, different means changed. That read-then-act-only-if-different loop is what makes them safe to re-run.
Now the trap. The command and shell modules are NOT idempotent by default. They just execute whatever you hand them — Ansible has no idea what the command does, so it runs every single time and always reports "changed". Run shell: useradd appuser twice and the second run errors ("user already exists") or, worse, a destructive command repeats. You make them safe two ways: creates: /path/to/marker (skip if that file already exists) or removes: (run only if it exists), and changed_when: (decide "changed" from the command's own output, e.g. changed_when: "'added' in result.stdout"). The golden rule: prefer a native module; reach for shell only when none exists, and then add creates/changed_when.
▶ Run the same playbook twice — watch idempotency work (and fail)
Sneha applies a config template, then immediately re-runs the exact same playbook. Follow what each module decides on run 1 vs run 2. Press Play for the healthy path, then Break it to see the failure.
# IDEMPOTENT — native module checks state itself
- name: Ensure NTP server line is present
ansible.builtin.lineinfile:
path: /etc/ntp.conf
line: "server 10.0.0.123 iburst"
# NOT idempotent until rescued — shell needs creates/changed_when
- name: Initialise the app DB (one-time)
ansible.builtin.shell: /opt/app/init-db.sh
args:
creates: /var/lib/app/.db_initialised
register: dbinit
changed_when: "'created' in dbinit.stdout"# RUN 1 changed: [BR-Mumbai] => (lineinfile added the server line) changed: [BR-Mumbai] => (init-db ran, marker written) # RUN 2 (idempotent — nothing to do) ok: [BR-Mumbai] (line already present) ok: [BR-Mumbai] (creates: marker exists → skipped)
Run ansible-playbook site.yml --check first: check mode is a dry run — modules report what they WOULD change without touching anything. Add --diff and you also see the exact before/after lines (the new interface stanza, the changed config block). Together they're your 'look before you leap': preview the blast radius, confirm only the hosts you expect show changed, then drop the flags to apply for real. Note: a task using shell with no check_mode support can't accurately predict itself in --check — another reason to prefer native modules.
Aditya at HCL faces this
Aditya's nightly compliance playbook reports BR-Pune as 'changed' on EVERY run, even though nobody touches the router. His 'reload syslog' handler therefore fires nightly and pages the on-call.
The task that 'checks' the config uses the shell module (`shell: show running-config | include logging`) with no changed_when. Shell always reports 'changed', so Ansible thinks something changed every single night — a false positive.
He runs the playbook with --diff and sees the shell task has no actual file/state diff, yet still flags changed. That points straight at a non-idempotent shell task rather than real drift.
ram@netauto:~/playbooks → ansible-playbook compliance.yml --check --diff (watch which task shows changed with no diff)Replace the shell check with a native module where possible, or add changed_when: false to the read-only shell task (a pure 'gather' command changes nothing), so it stops reporting changed.
Re-run twice: the second run shows changed=0 for BR-Pune, the syslog handler no longer fires, and the on-call stops getting nightly pages.
Meera at Flipkart needs a one-time script `/opt/seed.sh` to run only if it hasn't run before. Which approach makes the shell task idempotent with the least fuss?
Pause & Predict
Predict: you run `ansible-playbook site.yml --check` and a task that uses the shell module shows as 'skipped' or an odd result, while template tasks show clean predicted diffs. Why does shell behave differently in check mode? Type your guess.
④ Putting it together — render, --check/--diff, then apply
Time to wire the pieces into the workflow you'll actually run. Neha at Airtel has a templated config and a fleet of routers. Her safe-apply loop is always the same three beats: render the Jinja2 template per host, preview with --check --diff, then apply for real — pushing the rendered config with a network module so it stays idempotent on every future run.
For network gear the rendered text usually goes out through a network module — cisco.ios.ios_config or ansible.netcommon.cli_config — which compares your lines against the device's running-config and only sends what differs. These modules also support backup: yes (save the running-config before changing it) and respect check mode, so the same --check --diff habit works end to end.
- name: Render + push branch config
hosts: branch_routers
gather_facts: false
tasks:
- name: Render branch.j2 to a per-host file
ansible.builtin.template:
src: branch.j2
dest: "/tmp/{{ inventory_hostname }}.cfg"
- name: Push config to the router (idempotent, with backup)
cisco.ios.ios_config:
src: "/tmp/{{ inventory_hostname }}.cfg"
backup: true# ansible-playbook push-config.yml --check --diff (dry run) changed: [BR-Mumbai] --- before / +++ after (+interface Gi0/2 …) ok: [BR-Pune] (running-config already matches) # drop --check to apply; re-run → ok, changed=0 everywhere
Three gotchas that bite real engineers, so you can dodge them: (1) a stray shell task breaks idempotency — it always says "changed"; model state with a native or network module, or pin it with changed_when. (2) Template whitespace — missing {%- -%} dashes leave blank lines that make a network module's diff noisy or rejected. (3) Undefined vars — a typo'd variable with no | default aborts the whole render; never assume a host_var exists.
One sober, current security note. Because Jinja2 renders templates, untrusted template input is dangerous. In March 2025, CVE-2025-27516 (CVSS 5.4, fixed in Jinja2 3.1.6) showed the |attr filter could break out of Jinja2's sandbox via a string's format method and execute Python. The lesson for you: never render attacker-controlled data as a template, mark untrusted values appropriately, and keep jinja2/ansible-core patched — a template engine is powerful precisely because it runs logic.
Idempotency is your housing-society gate-pass register. When a regular visitor arrives, the guard checks the register first: already entered today? Then nothing to write — that's ok, changed=0. New visitor? He makes one entry — that's changed=1. He never writes the same visitor's name ten times for ten glances at the gate. A shell task is a lazy guard who scribbles a fresh entry every time he looks up — the register fills with noise and you can't tell who actually arrived. Native modules are the diligent guard who reads before writing.
For your cert path this maps straight onto the RHCE EX294 blueprint. The objectives explicitly call out "use Jinja2 templates to deploy customized configuration files" and "create and use idempotent playbooks and roles" — exactly this lesson. In the hands-on exam you'll write a template that loops over variables and you'll be marked down if your playbook reports spurious changes on a re-run. So practise the habit: render → --check --diff → apply, and always run twice to confirm changed=0.
Cold, without notes: (1) say what ${JJ(' x ')} vs {% if %} do; (2) name two filters and what they fix (| default stops undefined-var crashes, | ipaddr derives a netmask); (3) define idempotency in one line (run N times → same state, 'changed' only on a real change); (4) say why shell isn't idempotent and the two fixes (creates, changed_when); (5) name the two flags that preview a change (--check, --diff). If all five come out clean, you're ready for the next lesson and for the EX294 template/idempotency tasks.
An interviewer asks Arjun: "Why is running your config playbook a second time supposed to report changed=0, and how do you guarantee it?" Best answer?
🤖 Ask the AI Tutor
Tap any question — instant, scoped to this lesson. No login, no waiting.
Pre-curated from Ansible docs + community Q&A, scoped to this lesson. For a live prod issue, paste your export into chat.techclick.in.
📝 Wrap-up assessment — six more
You've answered 4 inline. Six left. 70% (7 of 10) marks the lesson complete on your profile. Tap Submit all answers at the end.
🧠 In your own words
Type one line: In one line: why does a correctly written Ansible playbook report changed=0 on its second run, and how is that different from 'it didn't error'? Then compare to the expert version.
🗣 Teach a friend
Best way to lock it in — explain it in one line to a teammate. Tap to generate a paste-ready summary.
📖 Glossary
- Jinja2
- The templating engine Ansible uses for every variable expression and for .j2 template files.
- template module
- ansible.builtin.template — renders a Jinja2 template on the control node and copies the result to the target; idempotent.
- Substitution {{ }}
- Double-brace expression that evaluates and prints a variable's value into the output text.
- for / if
- Jinja2 control statements ({% for %}, {% if %}) that loop and branch but print nothing themselves.
- Filter
- A transform applied with a pipe |, e.g. | default(x), | upper, | ipaddr, | to_yaml; cleans or reshapes a value.
- default filter
- | default('x') supplies a fallback so an undefined variable doesn't crash the render.
- ipaddr filter
- ansible.utils.ipaddr — derives network/netmask/etc. from a CIDR; lives in the ansible.utils collection.
- Whitespace control
- Adding a minus to a tag ({%- -%}) trims surrounding newlines/spaces so loops and ifs don't leave blank lines.
- trim_blocks / lstrip_blocks
- Whitespace defaults: Ansible sets trim_blocks True (drop newline after a tag) and lstrip_blocks False (keep leading indent).
- Idempotency
- Run a task N times → same end state, and 'changed' is reported only when something genuinely changed.
- changed_when
- Tells Ansible how to decide a task is 'changed' (e.g. from stdout); use changed_when: false for read-only commands.
- creates / removes
- shell/command args: creates skips the task if a marker file exists; removes runs only if it exists — a cheap idempotency guard.
- Check mode (--check)
- A dry run: modules report what they WOULD change without altering the system; pair with --diff to see before/after lines.
📚 Sources
- Ansible Community Documentation — "Templating (Jinja2)" (template module renders on the control node before execution; templates/ + .j2 convention; standard Jinja2 filters/tests plus Ansible's own). docs.ansible.com/projects/ansible/latest/playbook_guide/playbooks_templating.html
- Ansible Community Documentation — "Using filters to manipulate data" (the | default filter for undefined vars, to_yaml / to_nice_yaml serialisation, upper, omit). docs.ansible.com/projects/ansible/latest/playbook_guide/playbooks_filters.html
- Ansible Community Documentation — "Using the ipaddr filter" (ipaddr migrated to the ansible.utils collection; netmask/network/prefix manipulation over the netaddr package). docs.ansible.com/projects/ansible/latest/collections/ansible/utils/docsite/filters_ipaddr.html
- Ansible Community Documentation — "cisco.ios.ios_config" network module (idempotent config sections matched against the running-config; backup: option; respects check mode; connection network_cli). docs.ansible.com/projects/ansible/latest/collections/cisco/ios/ios_config_module.html
- Community guidance on idempotent shell — "How to Use Ansible to Run Idempotent Shell Commands" / "Fix 'Changed Status' Idempotency Issues" (shell/command always report changed; rescue with creates/removes and changed_when; verify by running twice with --check --diff). oneuptime.com/blog (2026) · symfonycasts.com/screencast/ansible/idempotency-changed-when
- GitHub Security Advisory GHSA-cpwx-vrp4-4pq7 / CVE-2025-27516 — Jinja2 sandbox breakout via the |attr filter reaching str.format (CVSS 5.4, affects ≤3.1.5, fixed in Jinja2 3.1.6, published 5 Mar 2025). github.com/advisories/GHSA-cpwx-vrp4-4pq7
- Red Hat — "Red Hat Certified Engineer (RHCE) exam (EX294)" objectives: use Jinja2 templates to deploy customized configuration files; create and use idempotent playbooks and roles. redhat.com/en/services/training/ex294-red-hat-certified-engineer-rhce-exam-red-hat-enterprise-linux
What's next?
You can render configs and keep them idempotent. Next we point that power at security: an Ansible role that walks the CIS benchmark and hardens 100 Linux hosts in minutes — idempotently, so a re-run proves they're still compliant.