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

Source: https://ai.techclick.in/blog_ansible_jinja2_idempotency
Markdown: https://ai.techclick.in/blog_ansible_jinja2_idempotency.md
Publisher: Techclick Infosec Pvt Ltd

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

Ansible Jinja2 &amp; Idempotency: — Configs That Build Themselves, Safely Re-Run student learning map
                     A visual study map for Ansible Jinja2 &amp; Idempotency: — Configs That Build Themselves, Safely Re-Run showing learning path, evidence, traps, and practice sequence.

                     TECHCLICK STUDY MAP
                     Ansible Jinja2 &amp; Idempotency: — Configs That Build...
                     Ansible · learn the flow, prove with evidence, avoid unsafe shortcuts

   1. Start
   🎯 By the end you will be able to

   2. Understand
   Pick where you want to start

   3. Prove
   ① Jinja2 in Ansible — variables +...

   4. Practice
   ② Building real config — loop...

                     How to use this page
                     First build the mental model, then connect the concept to a realistic production decision. Finish by testing yourself.
                     Techclick Infosec Pvt Ltd | ai.techclick.in | Training Contact: WhatsApp +91 92772 29456

             Content-specific feature visual for this lesson: use it as the 60-second map before reading the full detail.

             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  &#123;&#123; inventory_hostname &#125;&#125;  becomes the host's name and  &#123;&#123; ansible_host &#125;&#125;  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  &#123;&#123; ... &#125;&#125;  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

 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.j2 the blueprint (logic + vars) host_vars/ .yml the measurements (data) template module renders on the control node merge blueprint + data undefined var → fails unless | default(...) BR-Mumbai config Gi0/1 · VLAN 10/20 · OSPF 1 BR-Pune config Gi0/1 · VLAN 30 · OSPF 1 BR-Delhi config Gi0/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 idempotent rendered / config decision / check key insight idempotent / 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 &#123;&#123; inventory_hostname &#125;&#125; ! interface Loopback0 ip address &#123;&#123; loopback_ip &#125;&#125; 255.255.255.255 ! {% if site_type == "hub" %} banner motd ^HUB SITE - change control applies^ {% endif %} snmp-server location &#123;&#123; site_location | default("UNSET") | upper &#125;&#125; 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? a) description uplink b) The whole playbook fails with an 'undefined variable' error c) description (blank, nothing after it) d) description ${JJ(' link_desc ')} printed literally 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. Reveal answer 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 .yml; YAML data Ansible loads automatically for that host.">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 &#123;&#123; iface.ip &#125;&#125; 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 &#123;&#123; inventory_hostname &#125;&#125; ! {% for iface in interfaces %} interface &#123;&#123; iface.name &#125;&#125; description &#123;&#123; iface.desc | upper &#125;&#125; ip address &#123;&#123; iface.ip &#125;&#125; &#123;&#123; iface.mask &#125;&#125; no shutdown ! {% endfor -%} router ospf &#123;&#123; ospf_process &#125;&#125; {% for iface in interfaces %} network &#123;&#123; iface.ip | ansible.utils.ipaddr('network') &#125;&#125; 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 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 vars host_vars + group_vars + gathered facts 2 · load .j2 router.j2 Jinja2 engine 3 · substitute + loop {% for %} · {% if %} var → real value 4 · filters | default | upper | ipaddr | to_yaml 5 · write rendered config push to device / dest file ✗ break: variable has no value &#123;&#123; vlan_id &#125;&#125; undefined & no | default(...) → 'is undefined' error, render aborts Key idea: render is all-or-nothing nothing is pushed until the WHOLE template renders without error untrusted / not idempotent rendered / config decision / check key insight idempotent / 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. ram@netauto:~/playbooks → ansible-playbook push-config.yml --check --diff . (Recreated for clarity — your terminal matches this.)"> 🖥️ 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? a) Delete all the interfaces from host_vars b) Add whitespace-control dashes to the loop tags (e.g. {% endfor -%}) or set lstrip_blocks/trim_blocks c) Switch from the template module to the copy module d) Wrap every line in a | default filter 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. Reveal answer 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 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. 👉 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 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 appuser run 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 idempotent rendered / config decision / check key insight idempotent / 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 · read template reads current file, compares to rendered branch.j2 output ▼ ② Run 1 · act they differ → writes file, reports changed=1 ; handler 'reload' fires ▼ ③ Run 2 · read template re-reads file; rendered output is now identical ▼ ④ Run 2 · skip no difference → reports ok, changed=0 ; handler does NOT fire Press Play to step through the healthy path. Then press Break it . ▶ Play Next ▶ ⚠ Break it ↺ Reset 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? a) Add changed_when: true so it always reports changed b) Run it inside a loop 10 times c) Add args: creates: /var/run/seed.done so a marker file skips it on re-run d) Wrap the command in a | default filter 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. Reveal answer 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 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. 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/&#123;&#123; inventory_hostname &#125;&#125;.cfg" - name: Push config to the router (idempotent, with backup) cisco.ios.ios_config: src: "/tmp/&#123;&#123; inventory_hostname &#125;&#125;.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 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 value hostname &#123;&#123; inventory_hostname &#125;&#125; ip &#123;&#123; ansible_host &#125;&#125; double braces = print this var Loop + condition {% for i in interfaces %} {% if i.shutdown %} … {% endif %} {% endfor %} {% %} = logic, no output Filters | pipe &#123;&#123; vlan | default(1) &#125;&#125; &#123;&#123; name | upper &#125;&#125; &#123;&#123; cidr | ansible.utils.ipaddr('netmask') &#125;&#125; transform on the way out Whitespace control {%- trims left -%} trims right Ansible: trim_blocks ON, lstrip_blocks OFF by default kills blank lines from loops Render it - ansible.builtin.template: src: router.j2 dest: /etc/frr/frr.conf renders, then is idempotent Idempotency = run N times → same end state "changed" only on a REAL change native modules check state first Ansible's core promise shell is NOT idempotent runs every time, always "changed" Ansible can't see what it did avoid for state you can model Rescue shell creates: /path/marker changed_when: "'added' in out.stdout" teach it "already done" Safety net ansible-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? a) Because Ansible caches the first run, so the second is skipped b) Because idempotent native modules read current state and act only on a real difference, so a matching state is reported as ok c) Because --check is always on by default d) Because the shell module remembers what it ran last time 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. ${JJ(' x ')} vs {% x %} — when do I use which? Is 'idempotent' just a fancy word for 'doesn't error on re-run'? Why is the shell/command module 'not idempotent'? It runs my command fine. My template renders with blank lines everywhere — how do I clean it up? What exactly do --check and --diff give me, and is it safe? How much of RHCE EX294 does Jinja2 + idempotency actually cover? 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 ')}? a) It defines a loop that repeats a block b) It marks a comment that is not rendered c) It evaluates the variable and prints its value into the output d) It declares an idempotent task 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? a) vlan 99 — the default filter supplies a fallback so the missing var doesn't error b) The render fails with 'vlan_id is undefined' c) vlan (blank) — undefined vars render as empty d) vlan ${JJ(' vlan_id ')} printed literally 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? a) Set changed_when: true b) Add args: creates: /var/lib/app/.migrated so the marker file skips re-runs c) Loop the task 5 times d) Pipe the command through | upper 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? a) The template has an undefined variable b) The router's clock is wrong c) The shell task always reports 'changed' (no changed_when), so Ansible thinks something changed nightly d) Check mode is disabled 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? a) It already applied the change to all 200 — nothing more to do b) It's a dry run: only those 2 hosts would change; review the diff, then re-run without --check to apply c) The 198 'ok' hosts are unreachable d) --diff modified the configs, so you must roll back 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? a) A — pasting the whole config guarantees correctness every time b) A — shell is simpler so it's always safer c) Identical — both end up with the same config d) B — it renders per host from variables AND is idempotent (compares to running-config, changes only the diff, supports --check/--diff and backup) 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. Submit all answers Try again 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. Compare with expert answer 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. Generate my one-liner 📩 Quiz me on this in 7 days. Opt in and we'll email 3 micro-questions on Jinja2 & Idempotency at Day 1, Day 7 and Day 30 — spaced repetition is how this sticks. Un-tick any time. ### 📖 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 &#123;&#123; &#125;&#125; 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.

                 Next · Ansible for CIS Hardening: Securing 100 Hosts in Minutes →
                 Practice on exam.techclick.in →

---
Cite this Techclick lesson with the source URL. Do not invent fees, batch dates, or job guarantees.
Browse all lessons: https://ai.techclick.in/blogs
AI index: https://ai.techclick.in/llms.txt
