Yesterday’s inventory lived in YAML. Today that data turns into actual device configuration — without copy‑paste. Jinja2 is a templating engine: write a config once with placeholders, feed it a dict of values, and it renders a finished config. Generate one switch config or a hundred from the same template. If the phrase “copy‑paste drift” causes a twitch, this is the cure.
This is Day 16 of the 21‑post Python for Network Engineers series. Jinja2 is the same engine Ansible uses for its templates, so the syntax learned here transfers directly.
Install and the Simplest Possible Render
pip install jinja2
from jinja2 import Template
tmpl = Template("hostname {{ name }}")
print(tmpl.render(name="core-sw01"))
# hostname core-sw01
The {{ name }} is a placeholder. render() substitutes the value passed for name. That is the entire core idea — everything else is more elaborate substitution.
The Three Pieces of Jinja2 Syntax
{{ ... }}— expressions: print a value.{{ vlan_id }},{{ intf.description }}.{% ... %}— statements: logic likeforandif. These control flow but print nothing themselves.{# ... #}— comments: stripped from the output.
Those three delimiters cover every template written for network config. Here they are producing a VLAN list with a loop:
from jinja2 import Template
tmpl = Template("""{% for vlan in vlans %}vlan {{ vlan.id }}
name {{ vlan.name }}
{% endfor %}""")
vlans = [
{"id": 10, "name": "USERS"},
{"id": 20, "name": "VOICE"},
{"id": 30, "name": "MGMT"},
]
print(tmpl.render(vlans=vlans))
# vlan 10
# name USERS
# vlan 20
# name VOICE
# vlan 30
# name MGMT
Every {% for %} needs a matching {% endfor %}; every {% if %} needs an {% endif %}. Unlike Python, Jinja2 does not use indentation for blocks — it uses explicit end tags.
Conditionals: Optional Config Blocks
from jinja2 import Template
tmpl = Template("""interface {{ intf.name }}
description {{ intf.desc }}
{% if intf.shutdown %} shutdown
{% else %} no shutdown
{% endif %}""")
print(tmpl.render(intf={"name": "Gi0/1", "desc": "Uplink", "shutdown": False}))
# interface Gi0/1
# description Uplink
# no shutdown
Conditionals are what make one template cover real‑world variation — an interface that is sometimes shut, a VLAN that sometimes has a helper address, an OSPF block that only appears on routers.
Loading Templates From Files
Inline strings are fine for demos, but real templates live in .j2 files. The proper pattern uses an Environment with a FileSystemLoader pointed at a templates directory:
from jinja2 import Environment, FileSystemLoader
env = Environment(
loader=FileSystemLoader("templates"),
trim_blocks=True, # strip the newline after a {% %} tag
lstrip_blocks=True, # strip leading whitespace before a {% %} tag
)
tmpl = env.get_template("switch.j2")
print(tmpl.render(hostname="sw01", vlans=vlans))
trim_blocks and lstrip_blocks are almost always wanted for config generation — without them, every {% for %} and {% if %} leaves stray blank lines and indentation in the output. Turning them on keeps the rendered config clean.
Cisco Context: One Template, a Whole Access‑Layer Switch
Put the YAML skills from Day 15 together with Jinja2. A templates/access_switch.j2 file:
hostname {{ hostname }}
!
{% for vlan in vlans %}
vlan {{ vlan.id }}
name {{ vlan.name }}
{% endfor %}
!
{% for intf in interfaces %}
interface {{ intf.name }}
description {{ intf.desc }}
switchport mode access
switchport access vlan {{ intf.vlan }}
{% if not intf.enabled %}
shutdown
{% endif %}
!
{% endfor %}
And the driver that renders it from a data structure (which, in production, comes straight from a YAML file):
from jinja2 import Environment, FileSystemLoader
data = {
"hostname": "access-sw01",
"vlans": [{"id": 10, "name": "USERS"}, {"id": 20, "name": "VOICE"}],
"interfaces": [
{"name": "Gi0/1", "desc": "PC-101", "vlan": 10, "enabled": True},
{"name": "Gi0/2", "desc": "Phone-101", "vlan": 20, "enabled": True},
{"name": "Gi0/3", "desc": "SPARE", "vlan": 10, "enabled": False},
],
}
env = Environment(loader=FileSystemLoader("templates"),
trim_blocks=True, lstrip_blocks=True)
config = env.get_template("access_switch.j2").render(**data)
print(config)
with open(f"{data['hostname']}.cfg", "w") as f:
f.write(config)
Swap data for a loop over a YAML inventory and a single run generates a config file per switch. The template is the standard; the YAML is the per‑device data; Jinja2 is the press that stamps them together. This is exactly how config generation works in Ansible, Nornir, and every homegrown tool — now visible from the inside.
Filters: Transforming Values Inline
Jinja2 filters transform a value with a pipe, like a shell pipeline. A few are genuinely useful for config:
from jinja2 import Template
print(Template("{{ name | upper }}").render(name="core-sw01")) # CORE-SW01
print(Template("{{ vlans | join(',') }}").render(vlans=[10,20,30])) # 10,20,30
print(Template("{{ desc | default('no description') }}").render(desc=None))
# no description
default is especially handy — it fills in a fallback when an inventory omits an optional field, so the template does not error on missing data.
Exercises
- Warm-up. Render the string
"ip route 0.0.0.0 0.0.0.0 {{ gw }}"withgw="10.0.0.1". - Loop. Given
servers = ["10.0.0.10", "10.0.0.11"], use a{% for %}loop to render onentp server <ip>line per address. - Conditional. Write a template for an interface that includes the line
ip helper-address 10.0.0.53only when the variabledhcp_relayis true. - File template. Move the interface template from exercise 3 into
templates/intf.j2, load it with anEnvironment+FileSystemLoader(withtrim_blockson), and render it twice — once with the relay on, once off. - Challenge. Build a template that renders a full OSPF block:
router ospf {{ pid }}followed by onenetwork {{ n.cidr }} area {{ n.area }}line for each entry in anetworkslist, and apassive-interface {{ i }}line for each interface in apassivelist. Render it from a dict and confirm the output is paste‑ready IOS.
Answers
Show answers
1. Warm-up
from jinja2 import Template
print(Template("ip route 0.0.0.0 0.0.0.0 {{ gw }}").render(gw="10.0.0.1"))
# ip route 0.0.0.0 0.0.0.0 10.0.0.1
2. Loop
from jinja2 import Template
tmpl = Template("{% for ip in servers %}ntp server {{ ip }}\n{% endfor %}")
print(tmpl.render(servers=["10.0.0.10", "10.0.0.11"]))
# ntp server 10.0.0.10
# ntp server 10.0.0.11
3. Conditional
from jinja2 import Template
tmpl = Template("""interface {{ name }}
{% if dhcp_relay %} ip helper-address 10.0.0.53
{% endif %}""")
print(tmpl.render(name="Vlan10", dhcp_relay=True))
4. File template
{# templates/intf.j2 #}
interface {{ name }}
{% if dhcp_relay %}
ip helper-address 10.0.0.53
{% endif %}
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("templates"),
trim_blocks=True, lstrip_blocks=True)
t = env.get_template("intf.j2")
print(t.render(name="Vlan10", dhcp_relay=True))
print(t.render(name="Vlan20", dhcp_relay=False))
5. Challenge
from jinja2 import Template
ospf = Template("""router ospf {{ pid }}
{% for n in networks %} network {{ n.cidr }} area {{ n.area }}
{% endfor %}{% for i in passive %} passive-interface {{ i }}
{% endfor %}""")
print(ospf.render(
pid=1,
networks=[{"cidr": "10.0.0.0 0.0.0.255", "area": 0},
{"cidr": "10.0.1.0 0.0.0.255", "area": 0}],
passive=["GigabitEthernet0/3"],
))
# router ospf 1
# network 10.0.0.0 0.0.0.255 area 0
# network 10.0.1.0 0.0.0.255 area 0
# passive-interface GigabitEthernet0/3
Two loops in one template, each over a list of a different shape — the standard structure for any protocol block that has repeating sub‑commands.
Previously: YAML and JSON. Coming tomorrow — REST APIs with requests: talking to controllers like DNA Center and Meraki instead of screen‑scraping the CLI.