Chapter 13: Advanced Jinja2 Templating for Network Configuration

Learning Objectives

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?

A. {% variable %} B. {{ variable }} C. {# variable #} D. {$ variable $}

2. Inside a Jinja2 {% for %} loop, which built-in variable is True only on the final iteration?

A. loop.end B. loop.final C. loop.last D. loop.done

3. What does the Jinja2 default filter do?

A. Converts a value to its default Python type B. Sets the template's default whitespace mode C. Returns a fallback value when a variable is undefined or empty D. 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. ipaddress B. netaddr C. socket D. 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.

DelimiterPurposeExample
{{ ... }}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:

{{ 'enabled' if feature_enabled else 'disabled' }}
{{ 'Router-' + site_code + '-01' }}

Rendering Pipeline

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.

Interactive: Jinja2 Rendering Pipeline
YAML Data
+
.j2 Template
Jinja2 Environment
Rendered Config
template.render(hostname='R1-EDGE', bgp_asn=65001, interfaces=[...])
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

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:

VariableTypeDescription
loop.indexIntegerCurrent iteration (1-based)
loop.index0IntegerCurrent iteration (0-based)
loop.firstBooleanTrue on the first iteration
loop.lastBooleanTrue on the last iteration
loop.lengthIntegerTotal number of items
loop.revindexIntegerIterations 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 %}

For switchport mode — a classic exam scenario:

{% if interface.mode == 'trunk' %}
 switchport mode trunk
 switchport trunk encapsulation dot1q
{% elif interface.mode == 'access' %}
 switchport mode access
 switchport access vlan {{ interface.vlan }}
{% elif interface.mode == 'routed' %}
 no switchport
 ip address {{ interface.ip }} {{ interface.mask }}
{% else %}
 shutdown
{% 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

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

FilterExampleOutput / 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:

{% set cidr = '192.168.1.10/24' %}
Address:   {{ cidr | ansible.utils.ipaddr('address') }}   {# 192.168.1.10   #}
Network:   {{ cidr | ansible.utils.ipaddr('network') }}   {# 192.168.1.0    #}
Netmask:   {{ cidr | ansible.utils.ipaddr('netmask') }}   {# 255.255.255.0  #}
Prefix:    {{ cidr | ansible.utils.ipaddr('prefix') }}    {# 24             #}
Wildcard:  {{ cidr | ansible.utils.ipaddr('hostmask') }}  {# 0.0.0.255      #}

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

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.

{# Base template: base/router.j2 #}
hostname {{ hostname }}
!
{% block aaa %}
aaa new-model
aaa authentication login default local
{% endblock aaa %}
!
{% block interfaces %}{% endblock interfaces %}
!
{% block routing %}{% endblock routing %}
!
line vty 0 15
 login authentication default
 transport input ssh
{# Child template: devices/edge_router.j2 #}
{% extends 'base/router.j2' %}

{% block interfaces %}
{% from 'macros/interfaces.j2' import interface_config %}
{% for intf in interfaces %}
{{ interface_config(intf.name, intf.description,
    intf.ip_cidr | ansible.utils.ipaddr('address'),
    intf.ip_cidr | ansible.utils.ipaddr('netmask')) }}
{% endfor %}
{% endblock interfaces %}

{% block aaa %}
{{ super() }}
aaa authentication login MGMT-AUTH local
{% endblock aaa %}
graph TD BASE["base/router.j2\n─────────────\nblock: aaa\nblock: management\nblock: interfaces\nblock: routing\nShared: hostname, SSH, VTY lines"] EDGE["devices/edge_router.j2\n extends base/router.j2\n─────────────\noverrides: interfaces (ipaddr + macros)\noverrides: routing (BGP)\noverrides: aaa (super() + MGMT-AUTH)"] CORE["devices/core_switch.j2\n extends base/router.j2\n─────────────\noverrides: interfaces (SVI / VLANs)\noverrides: routing (OSPF)"] PE["devices/pe_router.j2\n extends base/router.j2\n─────────────\noverrides: interfaces (MPLS-aware)\noverrides: routing (BGP + OSPF)"] BASE --> EDGE BASE --> CORE BASE --> PE style BASE fill:#dbeafe,stroke:#2563eb style EDGE fill:#dcfce7,stroke:#16a34a style CORE fill:#dcfce7,stroke:#16a34a style PE fill:#dcfce7,stroke:#16a34a

Include vs. Import

Feature{% include %}{% import %}
What it doesRenders and inserts another template's full output inlineLoads macros/variables without rendering
Variable contextInherits caller's full context automaticallyDoes NOT inherit context (use with context)
Output producedYes — immediately rendered inlineNo — macros available to call explicitly
Best use casePolicy snippets: NTP, AAA, SNMP, loggingReusable parameterized macro libraries
{# Include policy snippets — they inherit all calling template variables #}
hostname {{ hostname }}
!
{% include 'snippets/aaa.j2' %}
{% include 'snippets/ntp.j2' %}
{% if mpls_enabled %}
{% include 'snippets/mpls.j2' %}
{% endif %}

Ansible and Nornir Integration

In Ansible, the template module renders a Jinja2 file using the current host's full variable context and writes the output to a destination path:

- name: Generate configuration from Jinja2 template
  template:
    src: devices/edge_router.j2
    dest: /tmp/configs/{{ inventory_hostname }}.cfg
  delegate_to: localhost

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

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?

A. {% variable %} B. {{ variable }} C. {# variable #} D. {$ variable $}

2. Inside a Jinja2 {% for %} loop, which built-in variable is True only on the final iteration?

A. loop.end B. loop.final C. loop.last D. loop.done

3. What does the Jinja2 default filter do?

A. Converts a value to its default Python type B. Sets the template's default whitespace mode C. Returns a fallback value when a variable is undefined or empty D. 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. ipaddress B. netaddr C. socket D. 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?

A. {{ vlans | join(',') | sort(attribute='id') }} B. {{ vlans | sort(attribute='id') | map(attribute='id') | join(',') }} C. {{ vlans | map(attribute='id') | join(',') | sort }} D. {{ vlans | unique | join(',') }}

7. A Jinja2 macro is defined with the parameter shutdown=False. What does this mean?

A. The macro will always render a shutdown command B. The macro will never render a shutdown command C. shutdown is a required positional argument that defaults to False at compile time D. 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 automatically B. include inherits the caller's full context automatically; import does not inherit context by default C. Both include and import require explicit variable passing D. 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?

A. {{ ip_cidr | ansible.utils.ipaddr('mask') }} B. {{ ip_cidr | ipaddr('subnet') }} C. {{ ip_cidr | ansible.utils.ipaddr('netmask') }} D. {{ ip_cidr | split('/') | last }}

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 function B. Inserts the parent template's aaa block content at that point in the child's block, then continues with the child's additions C. Loads the parent template file and re-renders it entirely D. 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=True B. trim_blocks=True, lstrip_blocks=True C. whitespace=False, newlines=False D. 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 rendering B. It validates that the password meets minimum length requirements C. It prevents a jinja2.UndefinedError when the password key is absent from some neighbor dictionaries D. 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 itself B. It renders the template and writes the output file on the Ansible control node rather than the managed device C. It delegates variable loading to a separate host in the inventory D. It disables Jinja2 rendering and copies the template file directly

Your Progress

Answer Explanations