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
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
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 %}
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 ─── #}
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
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 %}
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 %}
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
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 %}
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
)
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=Trueandlstrip_blocks=Trueon the Jinja2Environmentwhen 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 %}— theis definedcheck preventsUndefinedErrorwhen the key is entirely absent from the variable file. - CSV values are always strings — explicitly cast booleans (
"true"/"false") and integers after reading withcsv.DictReaderbefore passing totemplate.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
forandifcontrol structures, the role of the| default()filter, and how Jinja2 fits into a config generation + deployment pipeline alongside NAPALM or Ansible.
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.