Chapter 13: Advanced Jinja2 Templating for Network Configuration
Learning Objectives
Build advanced Jinja2 templates using loops, conditionals, and nested data structures to generate Cisco device configurations from structured data
Implement Jinja2 output modifiers and filters — including Ansible-specific filters like ipaddr and regex_replace — for formatted, deployment-ready configuration generation
Design reusable template libraries using macros, includes, and template inheritance to eliminate duplication across device roles
Integrate Jinja2 templates with both Ansible playbooks and standalone Python scripts to drive automated configuration workflows
Pre-Study Quiz
Answer these questions before reading to gauge your starting knowledge. Don't worry about guessing — these are for your own tracking.
Pre-Quiz — Jinja2 Fundamentals
1. Which Jinja2 delimiter is used to output a variable value into the rendered text?
2. Inside a Jinja2 {% for %} loop, which built-in variable is True only on the final iteration?
A. loop.endB. loop.finalC. loop.lastD. loop.done
3. What does the Jinja2 default filter do?
A. Converts a value to its default Python typeB. Sets the template's default whitespace modeC. Returns a fallback value when a variable is undefined or emptyD. Marks a block as the default block in a child template
4. In Jinja2 template inheritance, which keyword does a child template use to declare that it derives from a parent template?
A. {% inherit %}B. {% import %}C. {% include %}D. {% extends %}
5. What Python library must be installed for Ansible's ipaddr filter to work?
A. ipaddressB. netaddrC. socketD. pynetwork
Section 1: Jinja2 Fundamentals for Network Engineers
Jinja2 uses three distinct delimiter pairs. Everything outside delimiters is passed through verbatim — which is how static config text like interface or router ospf appears unchanged in rendered output.
Delimiter
Purpose
Example
{{ ... }}
Variable / expression output
{{ interface.name }}
{% ... %}
Control statements (loops, conditionals, macros)
{% for intf in interfaces %}
{# ... #}
Comments (not rendered in output)
{# TODO: add QoS #}
Variables are accessed using dot notation (device.hostname) or bracket notation (device['hostname']) interchangeably. Expressions inside {{ }} support arithmetic, string concatenation, comparisons, and inline conditionals:
When rendering in Python, you provide a dictionary of variables to the jinja2.Environment. The canonical network automation pattern stores device data in YAML and uses FileSystemLoader to resolve template and include paths.
from jinja2 import Environment, FileSystemLoader
import yaml
env = Environment(
loader=FileSystemLoader('templates/'),
trim_blocks=True,
lstrip_blocks=True
)
template = env.get_template('cisco_base.j2')
with open('host_vars/R1-EDGE.yml') as f:
device_data = yaml.safe_load(f)
config_output = template.render(**device_data)
Whitespace Control
Block tags like {% for %} occupy a full line, creating blank lines in output. Use {%- -%} (minus signs) to strip surrounding whitespace, or set trim_blocks=True and lstrip_blocks=True on the Environment — the most common production setting.
flowchart TD
A["YAML / JSON\nDevice Data"] --> C["Jinja2 Environment"]
B[".j2 Template File"] --> C
C --> D{"Template\nRenderer"}
D --> E["Rendered Config\nOutput String"]
E --> F{"Delivery Method"}
F --> G["Write to File\n.cfg"]
F --> H["Push via\nAnsible template module"]
F --> I["Deploy via\nNornir / NAPALM"]
style A fill:#dbeafe,stroke:#2563eb
style B fill:#dbeafe,stroke:#2563eb
style C fill:#fef9c3,stroke:#ca8a04
style D fill:#fef9c3,stroke:#ca8a04
style E fill:#dcfce7,stroke:#16a34a
style F fill:#f3e8ff,stroke:#9333ea
style G fill:#f0fdf4,stroke:#16a34a
style H fill:#f0fdf4,stroke:#16a34a
style I fill:#f0fdf4,stroke:#16a34a
Key Points — Section 1
{{ }} outputs values, {% %} is for control logic, {# #} is for comments — all three delimiters are required knowledge for the exam.
The canonical network automation pattern separates YAML data from Jinja2 templates so that data owners and template authors can work independently.
FileSystemLoader enables {% include %} and {% import %} to resolve relative file paths — required for multi-file template libraries.
trim_blocks=True combined with lstrip_blocks=True on the Environment is the standard production whitespace setting for network config templates.
Both dot notation (device.hostname) and bracket notation (device['hostname']) are valid and equivalent for accessing dictionary keys.
Section 2: Control Structures — Loops and Conditionals
The {% for %} loop transforms a list into repeated configuration blocks. Without loops, a template for ten interfaces would require ten manually written stanzas — defeating the purpose of templating entirely.
{% for interface in interfaces %}
interface {{ interface.name }}
description {{ interface.description }}
ip address {{ interface.ip }} {{ interface.mask }}
no shutdown
!
{% endfor %}
The loop Object
Jinja2 exposes a special loop object inside every {% for %} block:
Variable
Type
Description
loop.index
Integer
Current iteration (1-based)
loop.index0
Integer
Current iteration (0-based)
loop.first
Boolean
True on the first iteration
loop.last
Boolean
True on the last iteration
loop.length
Integer
Total number of items
loop.revindex
Integer
Iterations remaining (1-based)
Classic exam usage — generating a comma-separated VLAN list without a trailing comma:
switchport trunk allowed vlan {% for vlan in allowed_vlans %}{{ vlan }}{% if not loop.last %},{% endif %}{% endfor %}
Iterating Dictionaries and Nested Loops
When data uses dictionaries rather than lists, iterate with .items(). Nested loops handle structures like VRF-to-interface relationships:
{% for vrf in vrfs %}
ip vrf {{ vrf.name }}
rd {{ vrf.rd }}
!
{% for interface in vrf.interfaces %}
interface {{ interface.name }}
ip vrf forwarding {{ vrf.name }}
ip address {{ interface.ip }} {{ interface.mask }}
!
{% endfor %}
{% endfor %}
Conditionals — Branching on Device Role
{% if %} / {% elif %} / {% else %} allows a single template to serve multiple device roles. The is defined test guards against jinja2.UndefinedError when optional variables may be absent:
{% if neighbor.password is defined %}
neighbor {{ neighbor.ip }} password {{ neighbor.password }}
{% endif %}
flowchart TD
A["Start: interfaces list"] --> B{"More items\nin list?"}
B -- Yes --> C["Set loop variables\nloop.index, loop.first\nloop.last, loop.length"]
C --> D["Render interface block\nwith current item"]
D --> E{"loop.last?"}
E -- No --> B
E -- Yes --> F["End loop\nall stanzas rendered"]
B -- "No / Empty list" --> F
style A fill:#dbeafe,stroke:#2563eb
style B fill:#fef9c3,stroke:#ca8a04
style C fill:#fef9c3,stroke:#ca8a04
style D fill:#dcfce7,stroke:#16a34a
style E fill:#fef9c3,stroke:#ca8a04
style F fill:#f0fdf4,stroke:#16a34a
Key Points — Section 2
loop.last and loop.first are the most exam-relevant loop object attributes — used to control separators, headers, and footers within iteration.
Use .items() to iterate over dictionaries yielding (key, value) pairs.
Nested {% for %} loops are the correct pattern for hierarchical data like VRF-to-interface or router-to-neighbor relationships.
The is defined test prevents UndefinedError when templating against incomplete or variable inventory data — essential for production templates.
A single template with if/elif/else branching on interface.mode is the standard exam pattern for handling trunk, access, and routed port configurations.
Section 3: Filters and Output Modifiers
Filters transform data using the pipe (|) operator and can be chained left-to-right. Think of filters as a pipeline: data flows from left to right through each transformation before being written to output.
Key Built-in Filters
Filter
Example
Output / Effect
upper
{{ 'gi0/0' | upper }}
GI0/0
default(value)
{{ desc | default('Unset') }}
Unset if desc is undefined
join(sep)
{{ [10,20,30] | join(',') }}
10,20,30
replace(old, new)
{{ 'Gi0/0' | replace('Gi','GigabitEthernet') }}
GigabitEthernet0/0
sort
{{ vlans | sort(attribute='id') }}
List sorted by id
unique
{{ vlan_list | unique }}
Deduplicated list
length
{{ neighbors | length }}
Count of items
first / last
{{ interfaces | first }}
First / last list item
Filter Chaining
{# Sort VLANs by ID, then join IDs with commas for a trunk allowed list #}
switchport trunk allowed vlan {{ vlans | sort(attribute='id') | map(attribute='id') | join(',') }}
The Ansible ipaddr Filter
The ansible.utils.ipaddr filter (backed by Python's netaddr library) extracts network attributes from CIDR notation strings. Your YAML data model stores only ip_cidr: "192.168.1.10/24" — the template extracts address, mask, network, prefix, and wildcard at render time:
ipaddr returns False for invalid addresses, enabling defensive template logic. The ipv4 and ipv6 variants filter a mixed-family list to only addresses of that family.
The regex_replace Filter
Applies Python regex substitution, with back-reference support (\1, \2):
{# Normalize long interface names #}
{{ intf_name | regex_replace('GigabitEthernet', 'Gi') }}
{# Replace dots with underscores for hostname use #}
{{ router_id | regex_replace('\.', '_') }}
{# 10.0.0.1 → 10_0_0_1 #}
{# Extract third octet from subnet #}
{{ subnet | regex_replace('^(\d+)\.(\d+)\.(\d+)\.\d+.*$', '\3') }}
Custom Filters in Python
def wildcard_mask(prefix_length):
bits = (1 << (32 - int(prefix_length))) - 1
return '.'.join([str((bits >> (8 * i)) & 0xFF) for i in range(3, -1, -1)])
env.filters['wildcard'] = wildcard_mask
Filter Chain: vlans → trunk allowed VLAN string
vlans (list of dicts)→raw VLAN data from YAML
| sort(attribute='id')→ordered list by VLAN ID
| map(attribute='id')→extract only the id values
| join(',')→combine into a single string
"10,20,30,40"→ready for switchport trunk allowed vlan
flowchart TD
A["Raw Data: vlans list"] --> B["sort(attribute='id')\nOrder by VLAN ID"]
B --> C["map(attribute='id')\nExtract id values only"]
C --> D["join(',')\nCombine to CSV string"]
D --> E["Output: '10,20,30,40'"]
subgraph ipaddr_chain["ipaddr filter chain"]
F["ip_cidr: '192.168.1.10/24'"] --> G["ipaddr('address')\n→ 192.168.1.10"]
F --> H["ipaddr('netmask')\n→ 255.255.255.0"]
F --> I["ipaddr('network')\n→ 192.168.1.0"]
F --> J["ipaddr('prefix')\n→ 24"]
end
style A fill:#dbeafe,stroke:#2563eb
style B fill:#fef9c3,stroke:#ca8a04
style C fill:#fef9c3,stroke:#ca8a04
style D fill:#fef9c3,stroke:#ca8a04
style E fill:#dcfce7,stroke:#16a34a
style F fill:#dbeafe,stroke:#2563eb
style G fill:#dcfce7,stroke:#16a34a
style H fill:#dcfce7,stroke:#16a34a
style I fill:#dcfce7,stroke:#16a34a
style J fill:#dcfce7,stroke:#16a34a
Key Points — Section 3
Filters use the | pipe operator and chain left-to-right — sort | map | join is a single, readable expression that produces a VLAN trunk string.
The default filter is critical for production templates — it prevents rendering failures when optional YAML variables are absent from some devices.
The ansible.utils.ipaddr filter requires pip install netaddr and ansible-galaxy collection install ansible.utils — both are exam-relevant prerequisites.
ipaddr accepts arguments 'address', 'netmask', 'network', 'prefix', 'broadcast', and 'hostmask' — enabling a single CIDR YAML field to supply every template component.
Custom Python filter functions are registered on env.filters['name'] = function and are immediately usable in all templates loaded by that Environment.
Section 4: Advanced Template Patterns
Macros: Parameterized Configuration Functions
A macro is the Jinja2 equivalent of a function — it takes parameters, executes template logic, and renders output when called. Macros are ideal for configuration blocks that repeat with structural similarity but different values.
{% macro interface_config(name, description, ip, mask, shutdown=False) %}
interface {{ name }}
description {{ description }}
ip address {{ ip }} {{ mask }}
{% if not shutdown %}
no shutdown
{% else %}
shutdown
{% endif %}
!
{% endmacro %}
{# Call the macro: #}
{{ interface_config('GigabitEthernet0/0', 'WAN Link', '203.0.113.1', '255.255.255.252') }}
{{ interface_config('GigabitEthernet0/2', 'DECOMMISSIONED', '0.0.0.0', '0.0.0.0', shutdown=True) }}
Importing Macros Across Templates
Macros in shared library files are imported using two patterns:
{# Pattern 1: Import as a module namespace (prevents name collisions) #}
{% import 'macros/interfaces.j2' as iface %}
{% import 'macros/bgp.j2' as bgp %}
{{ iface.interface_config('Gi0/0', 'WAN', '203.0.113.1', '255.255.255.252') }}
{# Pattern 2: Import specific macros into current namespace #}
{% from 'macros/interfaces.j2' import interface_config, loopback_config %}
Important:{% import %} does NOT inherit the calling template's variable context by default. Use {% import ... with context %} or pass needed variables as explicit macro arguments.
Template Inheritance
Template inheritance models configuration structure as a hierarchy. A base template defines the skeleton with named {% block %} sections; child templates {% extends %} the base and override only the blocks they customize. The super() call within an overridden block inserts the parent block's content first, then appends the child's additions.
In Python / Nornir, the shared Environment is initialized once with custom filters registered, then used inside per-host task functions that run in parallel across the inventory.
flowchart TD
Q1{"Do you need\nrendered output\ninline?"} -- Yes --> Q2{"Does it need\nits own parameters?"}
Q1 -- No --> Q3{"Do you need\nreusable named\nmacros?"}
Q2 -- "No, uses caller's\nvariables automatically" --> INC["Use: include 'snippet.j2'\nBest for: NTP, AAA, SNMP\nContext: inherited automatically"]
Q2 -- "Yes, needs params" --> MAC1["Use: macro in same file\nor import from macro file\nCall with explicit arguments"]
Q3 -- Yes --> Q4{"Needs caller's\nvariables?"}
Q3 -- No --> NONE["Use variables directly\nin template"]
Q4 -- No --> IMP["Use: import 'macros/x.j2' as x\nCall: x.macro_name(args)\nContext: isolated"]
Q4 -- Yes --> IMPCTX["Use: import ... with context\nor pass variables as macro arguments"]
style INC fill:#dcfce7,stroke:#16a34a
style IMP fill:#dbeafe,stroke:#2563eb
style IMPCTX fill:#dbeafe,stroke:#2563eb
style MAC1 fill:#f3e8ff,stroke:#9333ea
style NONE fill:#f3f4f6,stroke:#6b7280
Key Points — Section 4
Macros are Jinja2's equivalent of functions: named, parameterized, and reusable — defined with {% macro name(params) %} and called with {{ name(args) }}.
Template inheritance ({% extends %} + {% block %}) is the DRY solution for multi-device-role template libraries — one base template change propagates to all child device types.
super() inside an overridden block inserts the parent's block content, allowing child templates to append rather than replace shared configuration sections.
{% include %} inherits the caller's full variable context automatically, making it ideal for self-contained policy snippets like NTP, SNMP, and AAA configurations.
{% import %} does NOT inherit context — pass needed variables as explicit macro arguments, or use {% import ... with context %} if access to global vars is required.
Post-Study Quiz
Now that you've read the chapter, answer these questions to test your understanding. Compare your score with the pre-quiz.
Post-Quiz — Advanced Jinja2 Templating
1. Which Jinja2 delimiter is used to output a variable value into the rendered text?
2. Inside a Jinja2 {% for %} loop, which built-in variable is True only on the final iteration?
A. loop.endB. loop.finalC. loop.lastD. loop.done
3. What does the Jinja2 default filter do?
A. Converts a value to its default Python typeB. Sets the template's default whitespace modeC. Returns a fallback value when a variable is undefined or emptyD. Marks a block as the default block in a child template
4. In Jinja2 template inheritance, which keyword does a child template use to declare that it derives from a parent template?
A. {% inherit %}B. {% import %}C. {% include %}D. {% extends %}
5. What Python library must be installed for Ansible's ipaddr filter to work?
A. ipaddressB. netaddrC. socketD. pynetwork
6. A Jinja2 template needs to generate a trunk port configuration. The vlans list must be sorted by id, then output as a comma-separated string. Which filter chain is correct?
7. A Jinja2 macro is defined with the parameter shutdown=False. What does this mean?
A. The macro will always render a shutdown commandB. The macro will never render a shutdown commandC. shutdown is a required positional argument that defaults to False at compile timeD. shutdown is an optional keyword argument with a default value of False when not supplied by the caller
8. What is the key behavioral difference between {% include %} and {% import %} regarding variable context?
A. include requires explicit variable passing; import inherits context automaticallyB. include inherits the caller's full context automatically; import does not inherit context by defaultC. Both include and import require explicit variable passingD. Both include and import automatically inherit the caller's full context
9. Given ip_cidr: "10.1.1.10/24" in YAML, which Ansible filter expression correctly extracts the subnet mask for a Cisco ip address command?
10. In a Jinja2 child template, a developer writes {{ super() }} inside an overridden {% block aaa %}. What does super() do?
A. Calls the parent template's Python rendering functionB. Inserts the parent template's aaa block content at that point in the child's block, then continues with the child's additionsC. Loads the parent template file and re-renders it entirelyD. Marks the end of the child block and falls back to the parent
11. Which jinja2.Environment settings combination is the standard production choice for eliminating unwanted blank lines from rendered network configurations?
A. strip_blocks=True, remove_whitespace=TrueB. trim_blocks=True, lstrip_blocks=TrueC. whitespace=False, newlines=FalseD. keep_trailing_newline=False, autoescape=True
12. A template contains {% if neighbor.password is defined %}. What problem does this guard against?
A. It prevents the password from being logged to syslog during renderingB. It validates that the password meets minimum length requirementsC. It prevents a jinja2.UndefinedError when the password key is absent from some neighbor dictionariesD. It checks whether the password variable matches the expected data type
13. In the Ansible template module, what does delegate_to: localhost accomplish for network configuration generation?
A. It forces the template to be rendered on the managed network device itselfB. It renders the template and writes the output file on the Ansible control node rather than the managed deviceC. It delegates variable loading to a separate host in the inventoryD. It disables Jinja2 rendering and copies the template file directly