TTechclick ⚡ XP 0% All lessons
Ansible · Templating · Jinja2 & IdempotencyInteractive · L1 / L2 / L3

Ansible Jinja2 & Idempotency: — Configs That Build Themselves, Safely Re-Run

You have one config to write — but 40 routers, each with its own hostname, IPs and VLANs. Jinja2 lets you write the config ONCE as a template and let Ansible render a unique file per device. Idempotency is the partner promise: run that playbook a hundred times and nothing changes unless something genuinely needs to. This lesson makes both click.

📅 2026-06-11 · ⏱ 13 min · 3 live demos · 4 infographics · 🏷 10-Q assessment + AI Tutor inline

⚡ Quick Answer

Ansible Jinja2 templates + idempotency for L1/L2 engineers and RHCE EX294: {{ }} substitution, for/if loops, filters (default, upper, ipaddr), the template module, why shell isn't idempotent, and the --check/--diff safety net.

🎯 By the end you will be able to

Read as:

Pick where you want to start

1

Jinja2 basics

Variables, loops, ifs and filters turn data into config.

2

Build real config

Loop interfaces + branch on site type, readably.

3

Idempotency

Re-run safe — why shell breaks the promise.

4

Put it together

Render, --check/--diff, then apply with confidence.

🧠 Warm-up — 3 questions, no score

Just notice which ones make you pause. We answer all three inside the lesson.

1. You have one router config but 40 branches, each with different IPs and VLANs. What does Jinja2 templating let you do?

Answered in Jinja2 basics.

2. You run the SAME playbook twice in a row. On a correctly written, idempotent playbook, what does the second run report?

Answered in Idempotency.

3. Before applying a risky config change to production routers, which Ansible flag previews what WOULD happen without making the change?

Answered in Build real config.

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.

👉 So far: one template renders many configs; ${JJ(' x ')} prints, {% for %}/{% if %} are logic. Next: filters that clean and transform the values.

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.

Figure 1 — One template + per-host vars → a full config per router
One Jinja2 template plus per-host variables renders a full device config for every router — write once, generate many The Ansible control node holds one Jinja2 template named router.j2 inside a templates directory, plus per-host variables in host_vars. The template module merges the template with each host's variables and renders a complete, host-specific configuration file that is pushed to each managed router. One template feeds three different branch routers, each getting its own hostname, IP and VLANs. The template is the blueprint; the variables are the per-house measurements; the rendered config is the finished build. One template + per-host vars → a full config for every router Control node templates/router.j2the blueprint (logic + vars) host_vars/<host>.ymlthe measurements (data) template module renders on the control node merge blueprint + data undefined var → fails unless | default(...) BR-Mumbai configGi0/1 · VLAN 10/20 · OSPF 1 BR-Pune configGi0/1 · VLAN 30 · OSPF 1 BR-Delhi configGi0/1 · VLAN 40 · OSPF 1 Write the template ONCE. Add a host_vars file per router. Ansible builds the rest. Daily-life: a tiffin label printer. The label LAYOUT (name, area, route) is the template; each customer's details are the variables; every printed label is a rendered, unique config. Change the layout once → every label updates. Change one customer → only their label changes. untrusted / not idempotentrendered / configdecision / checkkey insightidempotent / ok
Read left to right: one router.j2 blueprint plus a host_vars file per device feeds the template module, which renders a separate, complete config for Mumbai, Pune and Delhi. Write once, generate many.

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.

🔤
Substitution ${JJ(' x ')}
tap to flip

Double braces print a variable's value into the text. So: hostnames, IPs and VLAN ids drop in per host automatically.

🔁
Loop {% for %}
tap to flip

Repeat a block once per list item. So: one loop writes ten interface stanzas from a ten-item list — no copy-paste.

🔀
If {% if %}
tap to flip

Include a block only when a test is true. So: hub sites get OSPF area 0, spokes get a stub area — same template.

🧪
Filter | pipe
tap to flip

Transform a value on the way out: | default, | upper, | ipaddr. So: a missing var falls back instead of crashing the render.

Jinja2 template — templates/router.j2 (a tiny first taste)
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 }}
Expected output
# 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
Daily-life analogy — the dabbawala tiffin label

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.

Quick check · Q1 of 10

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?

Correct: a. The | default("uplink") filter supplies a fallback, so the missing variable renders as 'uplink' instead of crashing. Without the default filter, an undefined variable raises an error and aborts the render (that's option 2's case). Jinja2 never prints the braces literally, and it doesn't silently blank out an undefined var unless you tell it to.

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.

Answer: The render fails with a 'vlan_id is undefined' error and the template module aborts that host. Crucially, rendering is all-or-nothing on the control node: because it errored before producing output, nothing is pushed — the device does NOT get a half-written config. That's a feature: a typo in a var name fails loudly at render time, not silently on the box.

② 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.

host_vars/BR-Mumbai.yml — the nested data the template loops over
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
Expected output
# 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.

templates/branch.j2 — loop interfaces + branch on site type (note the whitespace dashes)
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 %}
Expected output
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.

👉 So far: nested data in host_vars, a loop + an if in branch.j2, and whitespace dashes to keep it clean. Next: see the render and diff for real, then idempotency.
Figure 2 — Inside one render — substitution before any push
How the template module renders: collect vars, run the Jinja2 engine, substitute and loop, write the file — undefined vars stop it cold A five-step render pipeline for the Ansible template module. Step 1 Ansible gathers variables from host_vars, group_vars and facts for the target host. Step 2 the Jinja2 engine loads templates/router.j2. Step 3 it substitutes double-brace expressions and runs for-loops and if-conditionals. Step 4 filters such as default, upper and ipaddr transform values. Step 5 the finished text is written to the device or a file. A break note shows that a referenced variable with no value and no default filter raises an undefined-variable error and the whole render fails before anything is written. Inside one render — substitution happens on the control node, before any push 1 · gather varshost_vars + group_vars+ gathered facts 2 · load .j2router.j2Jinja2 engine 3 · substitute + loop{% for %} · {% if %}var → real value 4 · filters| default | upper| ipaddr | to_yaml 5 · write rendered configpush to device / dest file ✗ break: variable has no value{{ vlan_id }} undefined & no | default(...)→ 'is undefined' error, render aborts Key idea: render is all-or-nothingnothing is pushed until the WHOLEtemplate renders without error untrusted / not idempotentrendered / configdecision / checkkey insightidempotent / ok
Follow steps 1–5: gather vars, load the .j2, substitute and loop, run filters, write the result. The red break shows an undefined variable with no default aborting the whole render before anything reaches the device.
🖥️ This is the terminal you'll actually watch — a render preview at ram@netauto:~/playbooks → ansible-playbook push-config.yml --check --diff. (Recreated for clarity — your terminal matches this.)
ram@netauto:~/playbooks
1
$ command
ansible-playbook -i inventory.ini push-config.yml --check --diff
2
TASK template
Render branch.j2 → running-config
3
BR-Mumbai (diff)
+interface GigabitEthernet0/2 / + description LAN-VOICE
4
BR-Pune (diff)
(no changes — already matches)
PLAY RECAP
Mumbai changed=1 Pune changed=0 failed=0
▶ run
Common mistake — the rendered config has blank lines everywhere

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.

Quick check · Q2 of 10

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?

Correct: b. Blank lines after a loop come from the newline following the block tag; whitespace dashes ({%- -%}) or trim_blocks/lstrip_blocks remove them at the source. Deleting interfaces removes the config you need; the copy module can't render Jinja2 logic; and | default has nothing to do with whitespace.

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.

Answer: Zero template edits — seven stanzas now render. That's the whole point of the loop: the template iterates over whatever is in the interfaces list, so adding data in host_vars automatically produces more output. Data changes in host_vars; logic stays in the template. This separation is exactly what scales one template to 40 routers.

③ 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 moduletemplate, 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.

👉 So far: idempotency = same end state + honest 'changed'; native modules check state first. Next: the two modules that quietly break the promise.

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.

Figure 3 — Native module vs raw shell — only one is safe to re-run
The same task two ways: a native module is idempotent and safe to re-run; raw shell always reports changed unless you bolt on creates or changed_when A two-column comparison of idempotency. Left column, red, the shell or command module: it runs the command every single time, always reports changed yellow, can do damage on a re-run, and is invisible to check mode. Right column, green, a native module like template, lineinfile or service: it reads current state first, only acts when something actually differs, reports ok green on a second run, and honours check mode and diff. The bottom row shows the rescue path for shell, adding creates, removes or changed_when to make it behave idempotently. Red marks the unsafe non-idempotent path; green marks the safe idempotent path. Idempotent or not? Native module vs raw shell — same outcome wanted shell / command (raw) ✗ runs the command every single run ✗ ALWAYS reports "changed" (yellow) ✗ can repeat side-effects / break things ✗ check mode (--check) can't predict it ✗ no built-in "is it already done?" check - shell: useradd appuserrun 2 → "user already exists",task FAILS / falsely "changed" native module (template, user…) ✓ reads current state FIRST ✓ acts only if something differs ✓ 2nd run = "ok" (green), no change ✓ honours --check and --diff ✓ safe to re-run a hundred times - ansible.builtin.user:run 2 → user present already,reports ok, changed=0 Rescue for shell: add creates: / removes: or changed_when: to teach it "already done" untrusted / not idempotentrendered / configdecision / checkkey insightidempotent / ok
Compare the two columns for the SAME goal. Left (red) shell/command: runs every time, always 'changed', can re-break things, invisible to check mode. Right (green) native module: reads state first, acts only on a real difference, second run is 'ok'.

▶ 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.

① Run 1 · readtemplate reads current file, compares to rendered branch.j2 output
② Run 1 · actthey differ → writes file, reports changed=1; handler 'reload' fires
③ Run 2 · readtemplate re-reads file; rendered output is now identical
④ Run 2 · skipno difference → reports ok, changed=0; handler does NOT fire
Press Play to step through the healthy path. Then press Break it.
Two tasks, same goal — one idempotent, one rescued shell
# 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"
Expected output
# 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)
Prove a change is safe BEFORE you apply it — the --check / --diff safety net

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.

Likely cause

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.

Diagnosis

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)
Fix

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.

Verify

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.

Quick check · Q3 of 10

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?

Correct: c. creates: points at a marker file; if it exists, Ansible skips the task — so the script runs once and is skipped forever after. changed_when: true makes it lie 'changed' every run (the opposite of idempotent); looping runs it more, not less; and | default is a Jinja2 filter, irrelevant to shell idempotency.

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.

Answer: Native modules implement check-mode support — they can compute the would-be change without applying it, so --check shows an accurate prediction and --diff shows the lines. The shell module just runs an arbitrary command; Ansible can't safely 'pretend' to run it, so by default it doesn't execute in check mode and can't predict its effect. That gap is yet another reason to model state with native modules instead of shell.

④ 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 modulecisco.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.

push-config.yml — render with template, push idempotently with the network module
- 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
Expected output
# 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.

Figure 4 — Jinja2 & idempotency — the one-card cheat-sheet
Jinja2 and idempotency on one card — the loop/if/filter syntax and the rules that keep a playbook safe to re-run A nine-tile cheat sheet. Tiles cover variable substitution with double braces, the for-loop and if-conditional syntax, the most-used filters default upper ipaddr to_yaml, whitespace control with the minus signs, the template module task, what idempotency means, why shell is not idempotent, the creates and changed_when rescue, and the check-and-diff safety net commands. Each tile has one role line. Jinja2 & Idempotency — your one-glance card Substitute a valuehostname {{ inventory_hostname }}ip {{ ansible_host }}double braces = print this var Loop + condition{% for i in interfaces %}{% if i.shutdown %} … {% endif %}{% endfor %}{% %} = logic, no output Filters | pipe{{ vlan | default(1) }}{{ name | upper }}{{ cidr | ansible.utils.ipaddr('netmask') }}transform on the way out Whitespace control{%- trims left -%} trims rightAnsible: trim_blocks ON,lstrip_blocks OFF by defaultkills blank lines from loops Render it- ansible.builtin.template: src: router.j2 dest: /etc/frr/frr.confrenders, then is idempotent Idempotency = run N times → same end state"changed" only on a REAL changenative modules check state firstAnsible's core promise shell is NOT idempotentruns every time, always "changed"Ansible can't see what it didavoid for state you can model Rescue shellcreates: /path/markerchanged_when: "'added' in out.stdout"teach it "already done" Safety netansible-playbook site.yml --check--diff (dry-run + show the change)look before you leap
Your one-glance map of the whole lesson: the loop/if/filter syntax, whitespace control, the template task, what idempotency means, why shell breaks it, the creates/changed_when rescue, and the --check/--diff safety net.
Daily-life analogy — the society gate-pass register

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.

Next: Ansible for CIS Hardening — securing 100 hosts in minutes
Prove you own it — the 30-second self-test

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.

Quick check · Q4 of 10

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?

Correct: b. Idempotency comes from native modules reading current state and only acting on a real difference — so the second run finds nothing to change and reports ok/changed=0. Ansible doesn't cache runs; --check is opt-in, not default; and the shell module has no memory of past runs (which is exactly why it isn't idempotent).

🤖 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.

Q5 · Remember

In an Ansible Jinja2 template, what is the role of a double-brace expression like ${JJ(' ansible_host ')}?

Correct: c. Double braces are a print expression: Jinja2 evaluates ansible_host and substitutes its value into the rendered text. Loops use {% for %}; comments use {# … #}; and tasks/idempotency are playbook concerns, not template syntax.
Q6 · Apply

A template line reads `vlan ${JJ(' vlan_id | default(99) ')}` and a particular host has no vlan_id defined. What renders, and why?

Correct: a. | default(99) gives a fallback value when vlan_id is undefined, so it renders 'vlan 99' instead of erroring. Without the default filter it WOULD fail (option 2). Jinja2 doesn't silently blank undefined vars by default, and it never prints the braces literally.
Q7 · Apply

You must run a one-time `/opt/migrate.sh` only if it hasn't run before, using the shell module. Which addition makes it idempotent with the least effort?

Correct: b. creates: points at a marker file — if it exists, Ansible skips the task, so the script runs once and is skipped thereafter. changed_when: true forces a false 'changed' every run; looping runs it more, not once; and | upper is a Jinja2 filter, unrelated to shell idempotency.
Q8 · Analyze

A nightly playbook reports a router as 'changed' every single run with no real configuration drift, and a 'reload' handler keeps firing. The 'check' step uses `shell: show running-config | include ntp`. Most likely root cause?

Correct: c. A read-only shell command still reports 'changed' by default, so the playbook flags change every run and the handler fires — classic non-idempotent shell. Fix with changed_when: false or a native module. An undefined var would abort the render, not report a clean 'changed'; the clock and check-mode toggle don't cause this.
Q9 · Analyze

Before applying a config change to 200 production routers, you run `ansible-playbook push.yml --check --diff`. Two hosts show a diff; the rest show 'ok'. What does this tell you, and what's the safe next step?

Correct: b. --check is a dry run that changes nothing; --diff shows the before/after, so you've confirmed exactly 2 hosts would change and what the change is. The safe step is to drop --check and apply. Check mode never applies anything (so options 1 and 4 are wrong), and 'ok' means already-matching, not unreachable.
Q10 · Evaluate

Two ways to push a per-host config: (A) a shell task that pastes the full config every run; (B) a template task rendering branch.j2, pushed with cisco.ios.ios_config. Which is the stronger design and why?

Correct: d. B separates data (host_vars) from logic (template) so one template serves every host, and the network module compares against the running-config to change only what differs — idempotent, previewable with --check/--diff, and backed up. A's shell paste always reports 'changed', can't preview accurately, and fires handlers needlessly; 'simpler' doesn't make it safer.
Lesson complete — saved to your profile.
Almost! You need 70% (7 of 10) — re-read the path that tripped you up and tap "Try again".

🧠 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.

Expert version: Because idempotent native modules read the current state and act only when it differs from the declared desired state — so on the second run nothing differs and it reports ok/changed=0; 'didn't error' is weaker, since a non-idempotent shell task can run without error yet still falsely report 'changed' every time.

🗣 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

  1. 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
  2. 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
  3. 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
  4. 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
  5. 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
  6. 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
  7. 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.