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:
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
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("[email protected]") }}
!
{% 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.
TEST WHAT YOU LEARNED
What is the purpose of trim_blocks=True and lstrip_blocks=True when creating a Jinja2 Environment for network config generation?
{% 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.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?
"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.What is the difference between {{ variable | default('N/A') }} and {{ variable | default('N/A', true) }} in a Jinja2 template?
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.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?
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.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?
{% 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.What does the selectattr filter do, and give a practical example of its use in a network config template?
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.A macro is defined in templates/common/macros.j2. What is the correct syntax to import and use it in templates/router_base.j2?
{% 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.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?
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.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?
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.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?
{% 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.Related Topics & Step-by-Step Tutorials
Related concepts and next steps:
- Network Automation Overview — automation overview — templates and IaC
- Ansible for Network Automation — Ansible uses Jinja2 for templates
- Ansible Playbook — Automate Cisco IOS Configuration
- Python Netmiko — Connect and Run Show Commands
- Python NAPALM — Multi-Vendor Network Automation