Jinja2 Templates for Config Generation

Hand-crafting individual router and switch configurations for a 50-device rollout is slow, inconsistent, and error-prone — a single missed no shutdown or a transposed IP octet can bring down a branch. Jinja2 is a Python templating engine that separates the structure of a configuration from the data that fills it. Write the template once — including loops for interfaces, conditionals for optional features, and reusable macros for repeated blocks — then feed it a YAML or CSV variable file for each device. Jinja2 renders a complete, syntactically correct IOS configuration in milliseconds, every time, with no copy-paste errors.

Jinja2 is the templating engine used internally by Ansible for its task templates and variable substitution. Understanding Jinja2 directly gives you the same capability outside of Ansible — in standalone Python scripts, CI/CD pipelines, or alongside NAPALM to both generate and push configurations in a single workflow. For the SSH mechanism that pushes generated configs to devices, see Python Netmiko. For RESTCONF-based config delivery, see Cisco RESTCONF Basics.

1. Jinja2 Core Concepts

The Separation of Template and Data

A Jinja2 workflow has exactly two inputs and one output. The template is a text file that looks like an IOS config with placeholder expressions. The variable file supplies the values for those placeholders. Rendering combines them:

  Template file  (router_base.j2)          Variable file  (site_a.yaml)
  ─────────────────────────────            ───────────────────────────
  hostname {{ hostname }}                  hostname: NetsTuts-R1
  !                                        mgmt_ip: 192.168.10.1
  interface {{ mgmt_int }}                 mgmt_int: GigabitEthernet1
   ip address {{ mgmt_ip }} {{ mask }}     mask: 255.255.255.0
   no shutdown                             banner: "Authorised access only"

                  │                                    │
                  └──────────── render() ──────────────┘
                                    │
                                    ▼
                        Generated config output:

                        hostname NetsTuts-R1
                        !
                        interface GigabitEthernet1
                         ip address 192.168.10.1 255.255.255.0
                         no shutdown
  

Jinja2 Delimiter Reference

Delimiter Purpose Example Output
{{ }} Variable / expression — outputs the value {{ hostname }} NetsTuts-R1
{% %} Statement — control flow (for, if, macro, block). Produces no output itself {% for vlan in vlans %} (loop body repeated)
{# #} Comment — ignored by the renderer, never appears in output {# TODO: add NTP #} (nothing)
{{ x | filter }} Filter — transforms the value before output {{ hostname | upper }} NETSTUTS-R1
{{ x | default('val') }} Default filter — output fallback if variable is undefined or empty {{ snmp_location | default('Unknown') }} Unknown

Install Jinja2

pip install jinja2 pyyaml

python3 -c "import jinja2; print(jinja2.__version__)"
3.1.3
  

2. Lab Topology & File Structure

  Project layout:
  ─────────────────────────────────────────────────
  config-gen/
  ├── templates/
  │   ├── router_base.j2          ← router template
  │   ├── switch_base.j2          ← switch template
  │   ├── common/
  │   │   ├── ntp_block.j2        ← reusable NTP snippet
  │   │   └── aaa_block.j2        ← reusable AAA snippet
  │   └── base.j2                 ← parent template (inheritance)
  ├── vars/
  │   ├── site_a_router.yaml      ← variable file for Site A router
  │   ├── site_b_router.yaml      ← variable file for Site B router
  │   └── devices.csv             ← multi-device CSV inventory
  ├── output/                     ← generated configs land here
  └── generate.py                 ← main Python script

  Target devices:
    NetsTuts-R1  192.168.10.1  IOS-XE router (Site A)
    NetsTuts-R2  192.168.20.1  IOS-XE router (Site B)
    NetsTuts-SW1 192.168.10.2  IOS switch     (Site A)
  

3. Step 1 — Basic Variable Substitution

Start with the simplest case: a template that replaces named placeholders with values from a dictionary. Every Jinja2 expression is enclosed in double curly braces:

templates/router_base.j2

{# ── Router Base Template — NetsTuts Config Generator ── #}
!
hostname {{ hostname }}
!
ip domain-name {{ domain }}
!
{# ── Banner ────────────────────────────── #}
banner motd ^
  {{ banner_text }}
  Unauthorised access is prohibited.
^
!
{# ── Management interface ──────────────── #}
interface {{ mgmt_interface }}
 description {{ mgmt_description | default("Management") }}
 ip address {{ mgmt_ip }} {{ mgmt_mask }}
 no shutdown
!
{# ── Default route ──────────────────────── #}
ip route 0.0.0.0 0.0.0.0 {{ default_gateway }}
!
{# ── SSH hardening ──────────────────────── #}
ip ssh version 2
ip ssh time-out 60
ip ssh authentication-retries 3
crypto key generate rsa modulus 2048
!
line vty 0 4
 login local
 transport input ssh
 exec-timeout 10 0
!
end
  
This template combines several configuration areas: hostname and banner (see Hostname, Banner & Password Configuration), SSH hardening (see SSH Configuration), and VTY line security (see Console & VTY Line Configuration).

vars/site_a_router.yaml

---
hostname: NetsTuts-R1
domain: netstuts.com
banner_text: "NetsTuts Site A — Authorised Users Only"
mgmt_interface: GigabitEthernet1
mgmt_description: "Uplink to Core Switch"
mgmt_ip: 192.168.10.1
mgmt_mask: 255.255.255.0
default_gateway: 192.168.10.254
  

generate.py — Minimal Render Script

#!/usr/bin/env python3
"""generate.py — Render a single Jinja2 template with YAML variables"""

from jinja2 import Environment, FileSystemLoader
import yaml

# ── Load template from the templates/ directory ───────────
env      = Environment(loader=FileSystemLoader("templates"),
                       trim_blocks=True,
                       lstrip_blocks=True)
template = env.get_template("router_base.j2")

# ── Load variable data ────────────────────────────────────
with open("vars/site_a_router.yaml") as f:
    variables = yaml.safe_load(f)

# ── Render and print ──────────────────────────────────────
config = template.render(variables)
print(config)

# ── Optionally save to file ───────────────────────────────
with open("output/NetsTuts-R1.cfg", "w") as f:
    f.write(config)
print("Config saved to output/NetsTuts-R1.cfg")
  
# ── Generated output ──────────────────────────────────────
!
hostname NetsTuts-R1
!
ip domain-name netstuts.com
!
banner motd ^
  NetsTuts Site A — Authorised Users Only
  Unauthorised access is prohibited.
^
!
interface GigabitEthernet1
 description Uplink to Core Switch
 ip address 192.168.10.1 255.255.255.0
 no shutdown
!
ip route 0.0.0.0 0.0.0.0 192.168.10.254
!
ip ssh version 2
ip ssh time-out 60
ip ssh authentication-retries 3
crypto key generate rsa modulus 2048
!
line vty 0 4
 login local
 transport input ssh
 exec-timeout 10 0
!
end
  
trim_blocks=True removes the newline after a {% %} tag, preventing blank lines where only a control statement appeared. lstrip_blocks=True strips leading whitespace before block tags so indentation in the template does not bleed into the output. Both options should always be set when generating IOS configurations — without them the output contains extra blank lines that are harmless but visually messy. The | default("Management") filter on mgmt_description means the template renders correctly even if that key is absent from the variable file. Save generated configs for audit purposes — see Saving and Managing Cisco Configurations.

4. Step 2 — Loops for Interfaces and VLANs

The {% for %} loop is the most powerful Jinja2 construct for network config generation. A switch with 48 ports, a router with 10 sub-interfaces, or an ACL with 30 entries can all be expressed as a single loop over a list in the variable file — no manual repetition in the template:

vars/site_a_switch.yaml — Switch Variable File

---
hostname: NetsTuts-SW1
domain: netstuts.com
mgmt_vlan: 99
mgmt_ip: 192.168.99.2
mgmt_mask: 255.255.255.0
default_gateway: 192.168.99.1

vlans:
  - id: 10
    name: Staff
  - id: 20
    name: Guest
  - id: 30
    name: VoIP
  - id: 99
    name: Management

access_ports:
  - interface: FastEthernet0/1
    vlan: 10
    description: "PC — Finance"
  - interface: FastEthernet0/2
    vlan: 10
    description: "PC — HR"
  - interface: FastEthernet0/3
    vlan: 20
    description: "Guest Laptop"
  - interface: FastEthernet0/4
    vlan: 30
    description: "IP Phone"

trunk_ports:
  - interface: GigabitEthernet0/1
    description: "Uplink to NetsTuts-R1"
    allowed_vlans: "10,20,30,99"
    native_vlan: 99
  

templates/switch_base.j2 — Loop Over VLANs and Ports

hostname {{ hostname }}
ip domain-name {{ domain }}
!
{# ── VLAN Database ───────────────────────── #}
{% for vlan in vlans %}
vlan {{ vlan.id }}
 name {{ vlan.name }}
!
{% endfor %}
{# ── Management SVI ──────────────────────── #}
interface Vlan{{ mgmt_vlan }}
 description Management
 ip address {{ mgmt_ip }} {{ mgmt_mask }}
 no shutdown
!
ip default-gateway {{ default_gateway }}
!
{# ── Access Ports ─────────────────────────── #}
{% for port in access_ports %}
interface {{ port.interface }}
 description {{ port.description }}
 switchport mode access
 switchport access vlan {{ port.vlan }}
 spanning-tree portfast
 no shutdown
!
{% endfor %}
{# ── Trunk Ports ──────────────────────────── #}
{% for trunk in trunk_ports %}
interface {{ trunk.interface }}
 description {{ trunk.description }}
 switchport mode trunk
 switchport trunk allowed vlan {{ trunk.allowed_vlans }}
 switchport trunk native vlan {{ trunk.native_vlan }}
 no shutdown
!
{% endfor %}
end
  
# ── Generated output (excerpt) ────────────────────────────
hostname NetsTuts-SW1
ip domain-name netstuts.com
!
vlan 10
 name Staff
!
vlan 20
 name Guest
!
vlan 30
 name VoIP
!
vlan 99
 name Management
!
interface Vlan99
 description Management
 ip address 192.168.99.2 255.255.255.0
 no shutdown
!
ip default-gateway 192.168.99.1
!
interface FastEthernet0/1
 description PC — Finance
 switchport mode access
 switchport access vlan 10
 spanning-tree portfast
 no shutdown
!
interface FastEthernet0/2
 description PC — HR
 switchport mode access
 switchport access vlan 10
 spanning-tree portfast
 no shutdown
!
...
interface GigabitEthernet0/1
 description Uplink to NetsTuts-R1
 switchport mode trunk
 switchport trunk allowed vlan 10,20,30,99
 switchport trunk native vlan 99
 no shutdown
!
end
  
Adding a new access port to the switch requires only one new entry in the access_ports list in the YAML file — the template itself never changes. This is the core productivity gain: the engineer who owns the network data (port assignments, VLANs) only edits the YAML; the template author defines the policy (portfast on all access ports, native VLAN always 99) once. Changes to policy affect all devices simultaneously by updating the template. For the manual equivalents of these commands, see VLAN Creation and Management, Assigning VLANs to Switch Ports, Trunk Port Configuration, and PortFast & BPDU Guard.

Loop Variables — Access Loop Metadata

{# ── loop.index: 1-based counter (loop.index0 for 0-based)  #}
{# ── loop.first / loop.last: true on first/last iteration   #}
{# ── loop.length: total items in the list                   #}

{% for vlan in vlans %}
{% if loop.first %}
! ── VLAN Configuration ({{ loop.length }} VLANs total) ────
{% endif %}
vlan {{ vlan.id }}
 name {{ vlan.name }}
{% if loop.last %}
! ── End of VLAN block ──────────────────────────────────────
{% endif %}
{% endfor %}
  
Jinja2 exposes a loop object inside every {% for %} block. The most useful attributes are loop.index (current iteration, 1-based), loop.first and loop.last (booleans for edge detection), and loop.length (total count of items). Use loop.first to generate a header comment before the first item, and loop.last to add a closing separator after the final item — without putting those lines inside the loop body where they would repeat for every item.

5. Step 3 — Conditionals for Optional Features

Not every device gets every feature. Edge routers need NAT; core routers do not. Some interfaces are serial; others are Gigabit. Jinja2 {% if %} blocks make features optional — they render only when the corresponding variable is defined and truthy:

vars/site_b_router.yaml — Site B Variables (With Optional Features)

---
hostname: NetsTuts-R2
domain: netstuts.com
mgmt_interface: GigabitEthernet1
mgmt_ip: 192.168.20.1
mgmt_mask: 255.255.255.0
default_gateway: 192.168.20.254

enable_nat: true
nat_inside_interface: GigabitEthernet2
nat_outside_interface: GigabitEthernet1
nat_acl_number: 1
nat_inside_network: 192.168.20.0
nat_inside_wildcard: 0.0.0.255

enable_ospf: true
ospf_process_id: 1
ospf_router_id: 2.2.2.2
ospf_networks:
  - network: 192.168.20.0
    wildcard: 0.0.0.255
    area: 0

enable_snmp: false

ntp_servers:
  - 216.239.35.0
  - 216.239.35.4

syslog_server: 192.168.99.10
  

templates/router_base.j2 — Conditional Feature Blocks

hostname {{ hostname }}
ip domain-name {{ domain }}
!
interface {{ mgmt_interface }}
 ip address {{ mgmt_ip }} {{ mgmt_mask }}
 no shutdown
!
ip route 0.0.0.0 0.0.0.0 {{ default_gateway }}
!
{# ── NTP — render only if ntp_servers list is defined and non-empty #}
{% if ntp_servers is defined and ntp_servers %}
{% for server in ntp_servers %}
ntp server {{ server }}
{% endfor %}
!
{% endif %}
{# ── Syslog ────────────────────────────────────────────── #}
{% if syslog_server is defined %}
logging host {{ syslog_server }}
logging trap informational
!
{% endif %}
{# ── NAT/PAT ───────────────────────────────────────────── #}
{% if enable_nat is defined and enable_nat %}
access-list {{ nat_acl_number }} permit {{ nat_inside_network }} {{ nat_inside_wildcard }}
!
interface {{ nat_inside_interface }}
 ip nat inside
!
interface {{ nat_outside_interface }}
 ip nat outside
!
ip nat inside source list {{ nat_acl_number }} interface {{ nat_outside_interface }} overload
!
{% endif %}
{# ── OSPF ──────────────────────────────────────────────── #}
{% if enable_ospf is defined and enable_ospf %}
router ospf {{ ospf_process_id }}
 router-id {{ ospf_router_id }}
{% for net in ospf_networks %}
 network {{ net.network }} {{ net.wildcard }} area {{ net.area }}
{% endfor %}
!
{% endif %}
{# ── SNMP ──────────────────────────────────────────────── #}
{% if enable_snmp is defined and enable_snmp %}
snmp-server community NetsTuts-RO ro
snmp-server location {{ snmp_location | default("Unknown") }}
snmp-server contact {{ snmp_contact | default("noc@netstuts.com") }}
!
{% endif %}
end
  
# ── Generated output for site_b_router.yaml (excerpt) ─────
hostname NetsTuts-R2
ip domain-name netstuts.com
!
interface GigabitEthernet1
 ip address 192.168.20.1 255.255.255.0
 no shutdown
!
ip route 0.0.0.0 0.0.0.0 192.168.20.254
!
ntp server 216.239.35.0
ntp server 216.239.35.4
!
logging host 192.168.99.10
logging trap informational
!
access-list 1 permit 192.168.20.0 0.0.0.255
!
interface GigabitEthernet2
 ip nat inside
!
interface GigabitEthernet1
 ip nat outside
!
ip nat inside source list 1 interface GigabitEthernet1 overload
!
router ospf 1
 router-id 2.2.2.2
 network 192.168.20.0 0.0.0.255 area 0
!
end

{# ── SNMP block was SKIPPED because enable_snmp: false ─── #}
  
The is defined test prevents Jinja2 from raising an UndefinedError when a variable is entirely absent from the variable file — as distinct from being present but set to false. The guard {% if enable_nat is defined and enable_nat %} handles both cases: skip if the key is missing, skip if the key is present but false. The SNMP block was skipped entirely because enable_snmp: false — the template produced clean output with no SNMP commands and no blank spaces where they would have appeared. For the manual config equivalents: NTP Configuration, Syslog Configuration, ACL Overview, OSPF Single-Area Lab, and SNMP Configuration.

if / elif / else — Multiple Conditions

{# ── Choose interface description based on role ─────────── #}
{% for port in interfaces %}
interface {{ port.name }}
{% if port.role == "uplink" %}
 description UPLINK — {{ port.peer }}
 switchport mode trunk
{% elif port.role == "access" %}
 description {{ port.description | default("Access Port") }}
 switchport mode access
 switchport access vlan {{ port.vlan }}
 spanning-tree portfast
{% elif port.role == "unused" %}
 description UNUSED — SHUT
 shutdown
{% else %}
 description {{ port.description | default("Unclassified") }}
{% endif %}
 no shutdown
!
{% endfor %}
  

6. Step 4 — Filters for Data Transformation

Filters transform variable values before they are inserted into the output. They are applied with the pipe operator (|) and can be chained. Jinja2 includes many built-in filters, and custom filters can be added to the Python Environment object:

Built-In Filters Most Useful for Network Templates

Filter Effect Example Output
upper Convert to uppercase {{ hostname | upper }} NETSTUTS-R1
lower Convert to lowercase {{ hostname | lower }} netstuts-r1
default(value) Use fallback if variable is undefined or empty {{ location | default('HQ') }} HQ
default(value, true) Use fallback if variable is undefined or falsy (empty string, 0, false) {{ desc | default('None', true) }} None (if desc is "")
replace(old, new) Replace all occurrences of a substring {{ hostname | replace('-', '_') }} NetsTuts_R1
int Cast to integer {{ vlan_id | int }} 10
string Cast to string {{ ospf_pid | string }} "1"
join(delim) Join a list into a string with delimiter {{ vlans | join(',') }} 10,20,30
length Length of string or list {{ interfaces | length }} 4
sort Sort a list {% for v in vlans | sort(attribute='id') %} VLANs in numeric order
selectattr(attr, test, val) Filter a list to items where an attribute matches {% for p in ports | selectattr('role','eq','access') %} Only access-role ports
unique Deduplicate a list {{ vlan_list | unique | join(',') }} No duplicate VLAN IDs

Custom Filter — IP to Wildcard Mask

#!/usr/bin/env python3
"""Custom Jinja2 filter: convert subnet mask to wildcard mask"""

from jinja2 import Environment, FileSystemLoader

def mask_to_wildcard(mask):
    """Convert '255.255.255.0' to '0.0.0.255'"""
    octets = mask.split(".")
    return ".".join(str(255 - int(o)) for o in octets)

env = Environment(loader=FileSystemLoader("templates"),
                  trim_blocks=True, lstrip_blocks=True)

# ── Register the custom filter ────────────────────────────
env.filters["wildcard"] = mask_to_wildcard

template = env.get_template("router_base.j2")
  
{# ── Using the custom filter in a template ─────────────── #}
access-list 1 permit {{ nat_inside_network }} {{ mgmt_mask | wildcard }}

{# ── Renders as: ──────────────────────────────────────────── #}
access-list 1 permit 192.168.10.0 0.0.0.255
  
Custom filters are registered on the env.filters dictionary with any name you choose. They become available in all templates loaded from that environment. The wildcard filter above means you only need to store the subnet mask in the variable file — the template derives the wildcard automatically, eliminating a common copy-paste error where the mask and wildcard are not complementary. See ACL Overview for how wildcard masks are used in access control lists.

7. Step 5 — Macros for Reusable Config Blocks

A Jinja2 macro is a reusable template function. Define it once, call it anywhere in the same template or import it into other templates. Macros are ideal for config blocks that repeat with slight variations — BGP neighbour entries, interface stanzas, or ACL lines:

Macro Defined and Used in the Same Template

{# ── Define a macro for a standard access port stanza ──── #}
{% macro access_port(interface, vlan, description, portfast=True) %}
interface {{ interface }}
 description {{ description }}
 switchport mode access
 switchport access vlan {{ vlan }}
{% if portfast %}
 spanning-tree portfast
{% endif %}
 no shutdown
!
{% endmacro %}

{# ── Call the macro for individual ports ─────────────────── #}
{{ access_port("FastEthernet0/1", 10, "PC — Finance") }}
{{ access_port("FastEthernet0/2", 20, "Guest Laptop") }}
{{ access_port("FastEthernet0/3", 30, "IP Phone", portfast=False) }}

{# ── Or call it inside a loop ────────────────────────────── #}
{% for port in access_ports %}
{{ access_port(port.interface, port.vlan, port.description) }}
{% endfor %}
  
The macro signature access_port(interface, vlan, description, portfast=True) works exactly like a Python function — positional arguments are required, keyword arguments with defaults are optional. Calling the macro without portfast uses the default value of True. The IP Phone port on FastEthernet0/3 passes portfast=False to suppress PortFast on a port where a phone may forward BPDUs from a downstream switch. See PortFast & BPDU Guard for why PortFast should be used carefully.

Importing Macros from a Separate File

{# ── File: templates/common/macros.j2 ───────────────────── #}

{% macro bgp_neighbor(peer_ip, remote_as, description="", update_source="") %}
 neighbor {{ peer_ip }} remote-as {{ remote_as }}
{% if description %}
 neighbor {{ peer_ip }} description {{ description }}
{% endif %}
{% if update_source %}
 neighbor {{ peer_ip }} update-source {{ update_source }}
{% endif %}
 neighbor {{ peer_ip }} send-community
{% endmacro %}

{% macro ospf_network(network, wildcard, area) %}
 network {{ network }} {{ wildcard }} area {{ area }}
{% endmacro %}
  
{# ── File: templates/router_base.j2 — import and use macros #}
{% from "common/macros.j2" import bgp_neighbor, ospf_network %}

router bgp {{ bgp_as }}
 bgp router-id {{ bgp_router_id }}
{% for peer in bgp_peers %}
{{ bgp_neighbor(peer.ip, peer.remote_as,
                peer.description | default(""),
                peer.update_source | default("")) }}
{% endfor %}
!
router ospf {{ ospf_process_id }}
 router-id {{ ospf_router_id }}
{% for net in ospf_networks %}
{{ ospf_network(net.network, net.wildcard, net.area) }}
{% endfor %}
  
The bgp_neighbor macro encapsulates all per-peer BGP commands in one reusable block. The ospf_network macro simplifies OSPF network statement generation. See OSPF Single-Area Lab for the manual OSPF configuration equivalent.

8. Step 6 — CSV-Driven Multi-Device Generation

For bulk rollouts, a CSV file is often the most convenient variable source — network teams maintain port assignments and device IPs in spreadsheets. Each row becomes one device config; each column is a variable:

vars/devices.csv

hostname,mgmt_ip,mgmt_mask,default_gateway,domain,site,enable_ospf,ospf_router_id
NetsTuts-R1,192.168.10.1,255.255.255.0,192.168.10.254,netstuts.com,SiteA,true,1.1.1.1
NetsTuts-R2,192.168.20.1,255.255.255.0,192.168.20.254,netstuts.com,SiteB,true,2.2.2.2
NetsTuts-R3,192.168.30.1,255.255.255.0,192.168.30.254,netstuts.com,SiteC,false,
  

generate_bulk.py — Generate One Config Per CSV Row

#!/usr/bin/env python3
"""generate_bulk.py — Generate device configs from a CSV inventory"""

import csv
import os
from jinja2 import Environment, FileSystemLoader

env = Environment(loader=FileSystemLoader("templates"),
                  trim_blocks=True, lstrip_blocks=True)
template = env.get_template("router_base.j2")

os.makedirs("output", exist_ok=True)

with open("vars/devices.csv", newline="") as csvfile:
    reader = csv.DictReader(csvfile)
    for row in reader:
        # ── CSV values are always strings — cast booleans ──
        row["enable_ospf"] = row["enable_ospf"].lower() == "true"

        hostname = row["hostname"]
        config   = template.render(row)

        outfile  = f"output/{hostname}.cfg"
        with open(outfile, "w") as f:
            f.write(config)
        print(f"Generated {outfile}")
  
# ── Output ────────────────────────────────────────────────
Generated output/NetsTuts-R1.cfg
Generated output/NetsTuts-R2.cfg
Generated output/NetsTuts-R3.cfg
  
csv.DictReader automatically uses the first row as column names, producing one dictionary per subsequent row — a perfect match for Jinja2's render() which accepts a dictionary. The critical catch is that CSV has no type system — every value is a string. Booleans like "true" must be explicitly cast before being passed to the template, otherwise {% if enable_ospf %} evaluates the non-empty string "false" as truthy and renders the OSPF block even when it should be skipped. Always cast booleans and integers from CSV input before rendering. Store the generated .cfg files as an audit trail — see Saving and Managing Cisco Configurations.

9. Step 7 — Template Inheritance

Template inheritance lets you define a base template with shared sections and create child templates that override only the parts specific to each device type. The base template defines {% block %} placeholders; child templates use {% extends %} and override individual blocks:

templates/base.j2 — Parent Template

{# ── base.j2 — shared configuration applied to ALL devices ─ #}
!
hostname {{ hostname }}
ip domain-name {{ domain }}
!
{% block ntp %}
ntp server 216.239.35.0
ntp server 216.239.35.4
{% endblock %}
!
{% block aaa %}
username netauto privilege 15 secret {{ mgmt_password }}
line vty 0 4
 login local
 transport input ssh
 exec-timeout 10 0
{% endblock %}
!
ip ssh version 2
!
{% block interfaces %}
{# ── child templates override this block ─────────────────── #}
{% endblock %}
!
{% block routing %}
{# ── child templates override this block ─────────────────── #}
{% endblock %}
!
logging host {{ syslog_server | default("192.168.99.10") }}
logging trap informational
!
end
  
The base template enforces shared policy across all device types: NTP, AAA and SSH, and syslog. See Console & VTY Line Configuration for the VTY line commands used in the AAA block.

templates/router_child.j2 — Child Template for Routers

{% extends "base.j2" %}

{# ── Override the interfaces block with router-specific config #}
{% block interfaces %}
interface {{ mgmt_interface }}
 description Management
 ip address {{ mgmt_ip }} {{ mgmt_mask }}
 no shutdown
!
{% for intf in wan_interfaces %}
interface {{ intf.name }}
 description {{ intf.description }}
 ip address {{ intf.ip }} {{ intf.mask }}
 ip nat outside
 no shutdown
!
{% endfor %}
{% endblock %}

{# ── Override the routing block ──────────────────────────── #}
{% block routing %}
ip route 0.0.0.0 0.0.0.0 {{ default_gateway }}
!
{% if enable_ospf %}
router ospf {{ ospf_process_id }}
 router-id {{ ospf_router_id }}
{% for net in ospf_networks %}
 network {{ net.network }} {{ net.wildcard }} area {{ net.area }}
{% endfor %}
{% endif %}
{% endblock %}
  

templates/switch_child.j2 — Child Template for Switches

{% extends "base.j2" %}

{# ── Switches override NTP to use a local NTP server ─────── #}
{% block ntp %}
ntp server 192.168.99.1
{% endblock %}

{% block interfaces %}
{% for vlan in vlans %}
vlan {{ vlan.id }}
 name {{ vlan.name }}
!
{% endfor %}
interface Vlan{{ mgmt_vlan }}
 ip address {{ mgmt_ip }} {{ mgmt_mask }}
 no shutdown
!
{% for port in access_ports %}
interface {{ port.interface }}
 switchport mode access
 switchport access vlan {{ port.vlan }}
 spanning-tree portfast
 no shutdown
!
{% endfor %}
{% endblock %}

{# ── Switches use ip default-gateway, not ip route ────────── #}
{% block routing %}
ip default-gateway {{ default_gateway }}
{% endblock %}
  
The parent template guarantees that every device — regardless of type — always gets hostname, AAA, SSH, and syslog configuration (see Syslog Configuration). The child templates override only the blocks relevant to their device type. Adding a new policy to every device (e.g. a new NTP server) requires editing only base.j2 — all child templates inherit the change automatically. The switch child overrides the NTP block to use a local NTP server instead of the public one. The router child configures OSPF in the routing block and static routes as a fallback.

10. Step 8 — Generate and Push in One Pipeline

Combining Jinja2 config generation with NAPALM config push creates a complete end-to-end automation pipeline: render the config from template and variables, then push it directly to the device with a diff preview before committing:

#!/usr/bin/env python3
"""generate_and_push.py — Render config and push to device via NAPALM"""

import yaml
import napalm
from jinja2 import Environment, FileSystemLoader

def render_config(template_name, var_file):
    """Render a Jinja2 template with variables from a YAML file."""
    env = Environment(loader=FileSystemLoader("templates"),
                      trim_blocks=True, lstrip_blocks=True)
    template  = env.get_template(template_name)
    with open(var_file) as f:
        variables = yaml.safe_load(f)
    return template.render(variables), variables

def push_config(hostname, username, password, secret, config_str):
    """Push a rendered config string to a device using NAPALM merge."""
    driver = napalm.get_network_driver("ios")
    device = driver(hostname=hostname, username=username,
                    password=password,
                    optional_args={"secret": secret})
    try:
        device.open()
        device.load_merge_candidate(config=config_str)
        diff = device.compare_config()
        if not diff:
            print(f"  {hostname}: No changes needed.")
            device.discard_config()
            return
        print(f"  {hostname}: Pending diff:\n{diff}")
        confirm = input(f"  Apply to {hostname}? [yes/no]: ")
        if confirm.lower() == "yes":
            device.commit_config()
            print(f"  {hostname}: Committed.")
        else:
            device.discard_config()
            print(f"  {hostname}: Discarded.")
    except Exception as exc:
        print(f"  {hostname}: ERROR — {exc}")
        device.discard_config()
    finally:
        device.close()

# ── Main pipeline ─────────────────────────────────────────
config, vars_ = render_config("router_child.j2", "vars/site_a_router.yaml")

# ── Save generated config for audit trail ─────────────────
hostname = vars_["hostname"]
with open(f"output/{hostname}.cfg", "w") as f:
    f.write(config)
print(f"Config saved: output/{hostname}.cfg")

# ── Push to device ────────────────────────────────────────
push_config(
    hostname=vars_["mgmt_ip"],
    username="netauto",
    password="Aut0P@ss!",
    secret="En@ble99!",
    config_str=config
)
  
The generated config string is saved to the output/ directory before pushing — this creates an audit trail of exactly what was sent to each device. In a CI/CD pipeline, this file can be committed to a Git repository alongside the templates and variable files, giving full change history for every device configuration. See Saving and Managing Cisco Configurations for configuration management best practices. For a full treatment of the NAPALM push workflow including rollback, see Python NAPALM Multi-Vendor Automation.

11. Troubleshooting Jinja2 Templates

Error / Symptom Cause Fix
UndefinedError: 'hostname' is undefined The variable file is missing a key that the template references without a default Add the missing key to the variable file, or add | default('value') to the template expression to make it optional
Extra blank lines in output trim_blocks or lstrip_blocks not set on the Environment Always pass trim_blocks=True, lstrip_blocks=True when creating the Jinja2 Environment
TemplateSyntaxError: unexpected end of template A {% for %} or {% if %} block is missing its closing {% endfor %} or {% endif %} Every opening block tag requires a matching closing tag. Count your block pairs. Use an editor with Jinja2 syntax highlighting to spot unclosed blocks
Boolean condition always renders as truthy from CSV CSV has no type system — "false" is a non-empty string, which Python evaluates as True Explicitly cast: row["enable_nat"] = row["enable_nat"].lower() == "true" after reading each CSV row
TemplateNotFound: router_base.j2 The FileSystemLoader path does not point to the correct templates directory Use an absolute path or confirm the working directory when the script runs: FileSystemLoader(os.path.join(os.path.dirname(__file__), "templates"))
Child template renders only the overridden block, not the full parent {% extends %} must be the very first line of the child template — any content outside a {% block %} in a child template is ignored Move the {% extends "base.j2" %} tag to line 1, with no preceding whitespace or comments. All child content must be inside named {% block %} tags
Filter raises FilterArgumentError A filter was called with incorrect arguments — e.g. | join on a non-iterable, or a custom filter receiving the wrong type Test filters in isolation using the Python interactive shell: from jinja2 import Environment; env = Environment(); env.from_string("{{ x | join(',') }}").render(x=[1,2,3])

Key Points & Exam Tips

  • Jinja2 separates template structure (how a config looks) from variable data (the values that differ per device). The same template renders a correct configuration for any number of devices by feeding it different variable files.
  • The three delimiter types: {{ }} outputs a variable, {% %} is a control statement (for, if, macro, block — produces no output), and {# #} is a comment (never appears in output).
  • Always set trim_blocks=True and lstrip_blocks=True on the Jinja2 Environment when generating IOS configurations — these prevent extra blank lines caused by block tags.
  • Use {% for item in list %} to generate repeating config blocks (interfaces, VLANs, ACL entries, BGP neighbours) from a YAML list — the template never needs to change when the list grows.
  • Guard optional features with {% if variable is defined and variable %} — the is defined check prevents UndefinedError when the key is entirely absent from the variable file.
  • CSV values are always strings — explicitly cast booleans ("true" / "false") and integers after reading with csv.DictReader before passing to template.render().
  • Macros ({% macro name(args) %} ... {% endmacro %}) are reusable template functions. Define them in a shared file and import with {% from "file.j2" import macro_name %}.
  • Template inheritance ({% extends "base.j2" %} + {% block name %}) allows a base template to enforce shared policy (AAA, NTP, syslog) while child templates override device-type-specific blocks (interfaces, routing). {% extends %} must be the first line of the child template.
  • For the CCNA automation track: know the three delimiter types, the for and if control structures, the role of the | default() filter, and how Jinja2 fits into a config generation + deployment pipeline alongside NAPALM or Ansible.
Next Steps: To push the configs generated by this lab directly to devices, see Python NAPALM Multi-Vendor Automation for the merge/replace/diff workflow and Python Netmiko Show Commands for raw SSH delivery. To use Jinja2 templates inside Ansible playbooks with the template module, see Ansible IOS Configuration. For the REST-based alternative where configs are delivered as structured YANG data rather than CLI text, see Cisco RESTCONF Basics. For the broader programmability model, see Controller-Based Networking and Northbound and Southbound APIs. For saving and auditing the generated config files, see Saving and Managing Cisco Configurations.

TEST WHAT YOU LEARNED

1. What is the purpose of trim_blocks=True and lstrip_blocks=True when creating a Jinja2 Environment for network config generation?

Correct answer is B. Without these options, every {% for %}, {% if %}, {% endfor %}, and {% endif %} tag leaves a newline in the output exactly where the tag appeared. A template with 10 control statements produces 10 extra blank lines scattered through the generated config. trim_blocks=True removes the newline character immediately following any block tag, preventing blank lines. lstrip_blocks=True removes leading whitespace (spaces and tabs) before block tags, allowing you to indent block tags in the template for readability without those spaces appearing in the output. Both should always be set when generating IOS or any structured text format where whitespace is semantically meaningful.

2. A Jinja2 template contains {% if enable_nat %}. The variable file is a CSV where the enable_nat column contains the string "false". Will the NAT block render? Why?

Correct answer is D. This is a critical and very common bug in CSV-driven config generation. Python's truthiness rules evaluate any non-empty string — including the string "false" — as True. The string "false" has five characters and is therefore truthy. Jinja2 inherits Python's truthiness evaluation, so {% if "false" %} renders the block. CSV has no native boolean type — every value is a string. The programmer is responsible for converting string representations of booleans before passing variables to the template. The safest pattern is value.lower() == "true" which correctly converts "true"True and anything else (including "false", "FALSE", empty string) → False.

3. What is the difference between {{ variable | default('N/A') }} and {{ variable | default('N/A', true) }} in a Jinja2 template?

Correct answer is A. The Jinja2 default filter has two behaviours controlled by the optional boolean second argument. Without it, default only activates when the variable name is completely absent from the rendering context — a defined variable set to an empty string "" or None will pass through as-is (producing nothing in the output). With true, the filter activates for any falsy value: undefined, None, empty string, 0, empty list, or False. For network templates where an empty description is as problematic as a missing description, use | default("No description", true) to catch both cases.

4. A template loop uses loop.first and loop.last to add a header and footer comment around a VLAN block. The variable file contains only one VLAN. What happens?

Correct answer is C. When a list has exactly one item, the single iteration is simultaneously the first and the last. Jinja2 sets both loop.first and loop.last to True for that iteration. This means both the header block (guarded by {% if loop.first %}) and the footer block (guarded by {% if loop.last %}) will render around the single item. This is semantically correct — there is both a first item (which is also the last). The output will have the header comment, then the single VLAN entry, then the footer comment. See VLAN Creation and Management for VLAN configuration context.

5. In template inheritance, what is the constraint on the placement of {% extends "base.j2" %} in a child template, and what happens if it is violated?

Correct answer is D. Jinja2's template inheritance has two strict rules. First, the {% extends %} tag must be the first tag in the file — no whitespace, no comments, no variable expressions before it. Second, in a child template, anything written outside of a {% block name %}...{% endblock %} pair is completely ignored by the renderer — the parent template's structure is used as the frame, and only the content of named blocks is substituted. This is a common gotcha: engineers write config lines directly in the child template outside blocks and wonder why they do not appear in the output.

6. What does the selectattr filter do, and give a practical example of its use in a network config template?

Correct answer is B. selectattr(attribute, test, value) is equivalent to Python's filter(lambda x: getattr(x, attribute) == value, list). It returns a new iterable containing only the items from the original list where the test passes. A practical network use case: maintain a single interfaces list in the YAML file with a role field for each entry, then use {% for p in interfaces | selectattr("role", "eq", "access") %} to generate access port config (see Assigning VLANs to Switch Ports) and {% for p in interfaces | selectattr("role", "eq", "trunk") %} for trunk config (see Trunk Port Configuration) — all from one unified data source.

7. A macro is defined in templates/common/macros.j2. What is the correct syntax to import and use it in templates/router_base.j2?

Correct answer is A. Jinja2 provides two import mechanisms. {% from "file.j2" import macro_name %} imports specific named macros directly into the current template's namespace — they are then called without any prefix. {% import "file.j2" as m %} imports the entire file as a module object — macros are accessed as m.macro_name() with the module prefix, which Option C gets partially right but uses incorrect syntax (macros are called with {{ }} expression tags, not {% %} statement tags). The {% include %} tag (Option B) renders and outputs the included file's content directly — it does not import macros into scope. This is particularly useful for sharing BGP neighbour or OSPF config macros across multiple device templates.

8. A YAML variable file contains a list of NTP servers under the key ntp_servers. The template renders all of them with a {% for %} loop. For one device the ntp_servers key is completely absent from the YAML file. What happens when the template renders, and how should the template handle this?

Correct answer is C. By default, Jinja2's Environment uses Undefined as the type for missing variables, and accessing or iterating over an Undefined object in a {% for %} loop raises an UndefinedError at render time. This is not the same as an empty list — an empty list iterates zero times safely, but an undefined variable raises an exception. The correct guard pattern is {% if ntp_servers is defined and ntp_servers %}: the first clause catches undefined (prevents the UndefinedError), the second clause catches a defined but empty list (prevents an empty loop block). See NTP Configuration for the NTP server commands this generates.

9. What is the key advantage of using a Jinja2 macro for BGP neighbour configuration compared to writing the neighbour block directly in the template body repeated for each peer?

Correct answer is D. Macros embody the DRY (Don't Repeat Yourself) principle for config templates. When you define the BGP neighbour stanza as a macro, the policy for all BGP neighbours is in one place. If you need to add neighbor X send-community both to every peer across all templates, you change one line in the macro. Without macros, that same line needs to be added to every template that configures BGP neighbours — a process that is error-prone and likely to result in inconsistent policy across devices. Macros also support default arguments which allow optional configuration elements (like update-source) to be cleanly expressed — present for some peers, absent for others — without conditional spaghetti in the calling template. Jinja2 macros have no type checking and no performance difference from inline blocks.

10. An engineer has a base template that configures AAA, NTP, and syslog for all devices. A child template for switches overrides the NTP block to use a local NTP server. After rendering, the engineer notices the switch output has the correct local NTP server but is missing the AAA and syslog configuration entirely. What is the most likely cause?

Correct answer is C. There are two common template inheritance mistakes that produce this symptom. First: if {% extends "base.j2" %} is not the absolute first tag (any preceding whitespace, blank line, or comment causes issues), Jinja2 may not process the inheritance correctly or may raise an error. Second and more subtle: if the child template contains any content — even a blank line — outside of named {% block %} tags, Jinja2 silently ignores that content when rendering in inheritance mode, but it can also interfere with the parent structure. The result is a partial render where only the explicitly overridden NTP block appears, and the parent's AAA and syslog blocks are lost. The fix is to ensure {% extends "base.j2" %} is truly first, and that all child template content is inside {% block name %}...{% endblock %} pairs. {{ super() }} (Option D) is used to include the parent block's content within an overriding block — useful but not the cause here.