Python for Network Engineers — Part 8: Jinja2 — Generating Configs at Scale
Part 7 ended with a clean YAML inventory: device names, IPs, roles, and connection parameters. What it doesn’t contain is the configuration those devices should have — interface descriptions, IP addresses, VLAN assignments, routing protocol neighbours. That data belongs in YAML too. Jinja2 is what bridges the gap between your data and the config your devices need.
The pattern is simple: you write a template that describes the shape of a config, with placeholders where the device-specific values go. You give the template a dict of values. Jinja2 renders a complete, ready-to-deploy config. Change the data, re-render, get a different config — no editing templates per device, no copy-paste errors, no drift between sites.
This post builds a multi-vendor config generator backed by the YAML inventory from Part 7. By the end, a single Python script turns a structured data file into per-device configuration ready for deployment via Netmiko.
Installing Jinja2
uv pip install jinja2
Basic Syntax
Jinja2 uses three delimiter types. Keeping them straight is the first thing to internalise:
| Delimiter | Purpose |
|---|---|
{{ expression }} | Output — evaluates and inserts the result |
{% statement %} | Control — for loops, if blocks, assignments |
{# comment #} | Comment — ignored in rendered output |
A simple template:
hostname {{ device.name }}
!
interface Loopback0
description {{ device.role | upper }} loopback
ip address {{ device.loopback_ip }}
Given device = {"name": "spine-01", "role": "spine", "loopback_ip": "10.255.0.1"}, this renders to:
hostname spine-01
!
interface Loopback0
description SPINE loopback
ip address 10.255.0.1
The | is a filter — upper converts the string to uppercase. Filters are covered in full below.
Rendering a Template in Python
The most direct way — render from a string:
from jinja2 import Template
template_str = "hostname {{ name }}\ninterface Loopback0\n ip address {{ loopback }}"
template = Template(template_str)
output = template.render(name="spine-01", loopback="10.255.0.1")
print(output)
In practice, templates live in files. Use Environment with FileSystemLoader to load them from a directory:
from jinja2 import Environment, FileSystemLoader
env = Environment(
loader=FileSystemLoader("templates"),
trim_blocks=True, # strip newline after block tags
lstrip_blocks=True, # strip leading whitespace before block tags
)
template = env.get_template("eos_base.j2")
output = template.render(device=device_data)
trim_blocks and lstrip_blocks are almost always what you want for network config templates — they prevent the empty lines that control tags inject into output. Set them on every Environment you create.
Variables and Expressions
Variables in templates are the values you pass in via render(). Access dict keys with dot notation or square brackets — both work:
{{ device.name }}
{{ device["name"] }}
Nested access works the same way:
{{ device.interfaces[0].ip }}
Arithmetic and comparisons work as you’d expect:
{{ vlan.id + 1000 }}
{{ "WARNING: " ~ interface.name ~ " is down" }}
~ concatenates strings in Jinja2 (equivalent to Python’s + on strings).
Undefined variables cause a UndefinedError by default. Use the default filter to provide a fallback:
{{ device.description | default("No description") }}
{{ device.mtu | default(1500) }}
Conditionals
{% if interface.shutdown %}
shutdown
{% else %}
no shutdown
{% endif %}
{% if device.role == "spine" %}
router-id {{ device.loopback_ip }}
{% elif device.role == "leaf" %}
router-id {{ device.loopback_ip }}
maximum-paths 4
{% else %}
{# No special BGP config for this role #}
{% endif %}
Test for presence of a value with is defined and is not none:
{% if device.description is defined and device.description is not none %}
description {{ device.description }}
{% endif %}
Loops
for loops iterate over lists and dicts:
{% for vlan in vlans %}
vlan {{ vlan.id }}
name {{ vlan.name }}
!
{% endfor %}
With a list of dicts vlans = [{"id": 10, "name": "Management"}, {"id": 20, "name": "Servers"}], this produces:
vlan 10
name Management
!
vlan 20
name Servers
!
Loop variables. Inside a loop, Jinja2 provides a special loop object:
| Variable | Value |
|---|---|
loop.index | Current iteration (1-based) |
loop.index0 | Current iteration (0-based) |
loop.first | True on first iteration |
loop.last | True on last iteration |
loop.length | Total number of items |
{% for neighbour in bgp.neighbours %}
neighbor {{ neighbour.ip }} remote-as {{ neighbour.asn }}
neighbor {{ neighbour.ip }} description {{ neighbour.name }}
{% if not loop.last %}
!
{% endif %}
{% endfor %}
Iterating over a dict:
{% for intf_name, intf in interfaces.items() %}
interface {{ intf_name }}
ip address {{ intf.ip }}
{% endfor %}
Whitespace Control
This is the most common source of messy generated configs and the thing most Jinja2 tutorials underexplain.
Every {% %} tag in a template produces a newline in the output by default. With trim_blocks=True on your Environment, the newline after a block tag is stripped. With lstrip_blocks=True, leading whitespace before a block tag is stripped. Together, they give you clean output without having to use whitespace-control hyphens everywhere.
When you do need fine-grained control — for example, when you want to strip the newline before a specific tag but not all tags — use the - modifier:
{%- for vlan in vlans %} {# strip whitespace before this tag #}
vlan {{ vlan.id }}
name {{ vlan.name }}
{% endfor -%} {# strip whitespace after this tag #}
A practical example. Without whitespace control, this template:
{% for vlan in vlans %}
vlan {{ vlan.id }}
{% endfor %}
Produces (with a blank line above each vlan line):
vlan 10
vlan 20
With trim_blocks=True, lstrip_blocks=True:
vlan 10
vlan 20
Always use trim_blocks=True, lstrip_blocks=True. Add - modifiers only where you need additional control.
Filters
Filters transform values in the template. Apply them with |:
{{ interface.name | upper }} {# GIGABITETHERNET0/0/1 #}
{{ interface.name | lower }} {# gigabitethernet0/0/1 #}
{{ description | truncate(40) }} {# truncate long descriptions #}
{{ vlans | length }} {# number of items in a list #}
{{ allowed_vlans | join(",") }} {# 10,20,30 — for switchport trunk allowed #}
{{ value | int }} {# convert string to integer #}
{{ value | float }} {# convert to float #}
{{ value | string }} {# convert to string #}
default is particularly useful — it prevents UndefinedError and provides a sensible fallback when a key is absent:
{{ interface.description | default("") }}
{{ interface.mtu | default(1500) }}
{{ device.ntp_server | default("pool.ntp.org") }}
selectattr and rejectattr filter a list by attribute value:
{# Only configure interfaces that are active #}
{% for intf in interfaces | selectattr("active") %}
interface {{ intf.name }}
no shutdown
{% endfor %}
{# Skip loopbacks #}
{% for intf in interfaces | rejectattr("name", "match", "^Loopback") %}
interface {{ intf.name }}
spanning-tree portfast
{% endfor %}
Custom Filters
Register your own Python function as a Jinja2 filter on the Environment:
import ipaddress
def cidr_to_host_mask(cidr: str) -> str:
"""Convert '10.0.0.1/30' to '10.0.0.1 255.255.255.252' for IOS configs."""
network = ipaddress.ip_interface(cidr)
return f"{network.ip} {network.netmask}"
def cidr_to_wildcard(cidr: str) -> str:
"""Convert '10.0.0.0/8' to '10.0.0.0 0.255.255.255' for ACLs."""
network = ipaddress.ip_network(cidr, strict=False)
return f"{network.network_address} {network.hostmask}"
env = Environment(loader=FileSystemLoader("templates"), trim_blocks=True, lstrip_blocks=True)
env.filters["host_mask"] = cidr_to_host_mask
env.filters["wildcard"] = cidr_to_wildcard
Use them in templates:
{# Cisco IOS-XE interface config — converts CIDR to IOS address format #}
interface {{ interface.name }}
ip address {{ interface.ip | host_mask }}
{# ACL entry with wildcard mask #}
permit ip {{ source.network | wildcard }} any
Custom filters are where Jinja2 becomes genuinely powerful for network config generation — you model the data the way you think about it (CIDR notation) and let the filter handle vendor-specific formatting.
Macros
A macro is a reusable template function — define it once, call it many times:
{# Define a macro for a standard interface block #}
{% macro interface_block(name, ip, description="", mtu=1500, shutdown=False) %}
interface {{ name }}
{% if description %}
description {{ description }}
{% endif %}
ip address {{ ip | host_mask }}
mtu {{ mtu }}
{% if shutdown %}
shutdown
{% else %}
no shutdown
{% endif %}
!
{% endmacro %}
{# Call it for each interface #}
{% for intf in device.interfaces %}
{{ interface_block(intf.name, intf.ip, intf.description | default(""), intf.mtu | default(1500)) }}
{% endfor %}
Macros accept default argument values — any unspecified argument uses its default. They’re the Jinja2 equivalent of Python functions: write once, use everywhere.
Template Inheritance
Template inheritance lets you define a base template with placeholders (block tags) and override specific sections in child templates without duplicating shared content.
templates/base_interface.j2:
{# Shared structure for all vendors #}
{% block interface_header %}
interface {{ interface.name }}
{% endblock %}
{% if interface.description %}
description {{ interface.description }}
{% endif %}
{% block address_config %}{% endblock %}
{% block shutdown_config %}
no shutdown
{% endblock %}
!
templates/eos_interface.j2 (Arista):
{% extends "base_interface.j2" %}
{% block address_config %}
ip address {{ interface.ip }}
{% endblock %}
templates/ios_interface.j2 (Cisco IOS-XE):
{% extends "base_interface.j2" %}
{% block address_config %}
ip address {{ interface.ip | host_mask }}
{% endblock %}
{% block shutdown_config %}
{% if interface.shutdown %}
shutdown
{% else %}
no shutdown
{% endif %}
{% endblock %}
templates/fortios_interface.j2 (FortiOS):
{% extends "base_interface.j2" %}
{% block interface_header %}
config system interface
edit "{{ interface.name }}"
{% endblock %}
{% block address_config %}
set ip {{ interface.ip | host_mask }}
set allowaccess ping ssh
{% endblock %}
{% block shutdown_config %}
{% if interface.shutdown %}
set status down
{% else %}
set status up
{% endif %}
next
end
{% endblock %}
Select the right template per device at render time:
TEMPLATES = {
"arista_eos": "eos_interface.j2",
"cisco_xe": "ios_interface.j2",
"cisco_ios": "ios_interface.j2",
"fortinet": "fortios_interface.j2",
}
template_name = TEMPLATES[device.device_type]
template = env.get_template(template_name)
output = template.render(interface=interface_data)
The base template handles shared structure; child templates handle vendor-specific differences. Adding a new vendor means writing one new child template, not touching anything else.
Capstone: Multi-Device Config Generator
Let’s extend the YAML inventory from Part 7 to include per-device config data, then generate ready-to-deploy configs from it.
Updated inventory.yaml:
---
defaults:
username: admin
password: netauto
device_type: arista_eos
port: 22
devices:
- name: spine-01
host: 192.168.100.10
role: spine
loopback_ip: 10.255.0.1/32
interfaces:
- name: Ethernet1
description: "Link to leaf-01 Ethernet1"
ip: 10.0.0.0/31
- name: Ethernet2
description: "Link to leaf-02 Ethernet1"
ip: 10.0.0.2/31
- name: leaf-01
host: 192.168.100.11
role: leaf
loopback_ip: 10.255.0.2/32
vlans:
- id: 10
name: Management
- id: 20
name: Servers
interfaces:
- name: Ethernet1
description: "Uplink to spine-01 Ethernet1"
ip: 10.0.0.1/31
- name: Ethernet2
description: "Link to leaf-02 Ethernet2"
ip: 10.0.1.0/31
- name: leaf-02
host: 192.168.100.12
role: leaf
loopback_ip: 10.255.0.3/32
vlans:
- id: 10
name: Management
- id: 20
name: Servers
interfaces:
- name: Ethernet1
description: "Uplink to spine-01 Ethernet2"
ip: 10.0.0.3/31
- name: Ethernet2
description: "Link to leaf-01 Ethernet2"
ip: 10.0.1.1/31
Create templates/eos_config.j2:
{# Arista EOS full device config template #}
hostname {{ device.name }}
!
{# Loopback interface #}
interface Loopback0
description {{ device.role | upper }} loopback
ip address {{ device.loopback_ip }}
!
{# Physical interfaces #}
{% for intf in device.interfaces %}
interface {{ intf.name }}
description {{ intf.description | default("") }}
ip address {{ intf.ip }}
no shutdown
!
{% endfor %}
{# VLANs — only if defined for this device #}
{% if device.vlans is defined %}
{% for vlan in device.vlans %}
vlan {{ vlan.id }}
name {{ vlan.name }}
!
{% endfor %}
{% endif %}
{# NTP #}
ntp server 192.168.100.1
!
end
Create generate_configs.py:
"""Generate per-device configs from YAML inventory using Jinja2."""
import ipaddress
import sys
import yaml
from pathlib import Path
from jinja2 import Environment, FileSystemLoader, TemplateNotFound
INVENTORY_FILE = "inventory.yaml"
TEMPLATE_MAP = {
"arista_eos": "eos_config.j2",
"cisco_xe": "ios_config.j2",
"cisco_ios": "ios_config.j2",
"fortinet": "fortios_config.j2",
}
OUTPUT_DIR = Path("generated_configs")
def cidr_to_eos(cidr: str) -> str:
"""Convert CIDR to Arista EOS address format: '10.0.0.1/30'."""
return cidr # EOS accepts CIDR natively
def cidr_to_ios(cidr: str) -> str:
"""Convert CIDR to IOS format: '10.0.0.1 255.255.255.252'."""
iface = ipaddress.ip_interface(cidr)
return f"{iface.ip} {iface.netmask}"
def merge_defaults(device: dict, defaults: dict) -> dict:
"""Apply defaults to a device dict for any missing keys."""
merged = {**defaults, **device}
return merged
def main():
# Load inventory
with open(INVENTORY_FILE, encoding="utf-8") as f:
inventory = yaml.safe_load(f)
defaults = inventory.get("defaults", {})
devices = inventory.get("devices", [])
# Set up Jinja2 environment
env = Environment(
loader=FileSystemLoader("templates"),
trim_blocks=True,
lstrip_blocks=True,
)
# Register custom filters
env.filters["eos_addr"] = cidr_to_eos
env.filters["ios_addr"] = cidr_to_ios
# Generate configs
OUTPUT_DIR.mkdir(exist_ok=True)
generated = []
skipped = []
for device in devices:
device = merge_defaults(device, defaults)
device_type = device.get("device_type", "unknown")
template_name = TEMPLATE_MAP.get(device_type)
if not template_name:
skipped.append((device["name"], f"no template for {device_type}"))
continue
try:
template = env.get_template(template_name)
except TemplateNotFound:
skipped.append((device["name"], f"template file {template_name!r} not found"))
continue
rendered = template.render(device=device)
out_path = OUTPUT_DIR / f"{device['name']}.cfg"
out_path.write_text(rendered, encoding="utf-8")
generated.append(device["name"])
print(f" {device['name']:<12} → {out_path}")
print(f"\nGenerated: {len(generated)} config(s) in {OUTPUT_DIR}/")
if skipped:
print(f"\nSkipped ({len(skipped)}):")
for name, reason in skipped:
print(f" {name}: {reason}")
sys.exit(1)
if __name__ == "__main__":
main()
Run it:
python3 generate_configs.py
spine-01 → generated_configs/spine-01.cfg
leaf-01 → generated_configs/leaf-01.cfg
leaf-02 → generated_configs/leaf-02.cfg
Generated: 3 config(s) in generated_configs/
generated_configs/spine-01.cfg:
hostname spine-01
!
interface Loopback0
description SPINE loopback
ip address 10.255.0.1/32
!
interface Ethernet1
description Link to leaf-01 Ethernet1
ip address 10.0.0.0/31
no shutdown
!
interface Ethernet2
description Link to leaf-02 Ethernet1
ip address 10.0.0.2/31
no shutdown
!
ntp server 192.168.100.1
!
end
The generated configs are plain text files you can diff in Git, review with a colleague, and deploy via Netmiko’s send_config_from_file() — completing the loop from Part 5.
Add a Git commit for the generated configs:
git add generated_configs/
git commit -m "Generate configs for spine-01, leaf-01, leaf-02"
Committing generated configs alongside templates and data is valuable — the diff between runs shows exactly what changed, which makes change management reviews concrete rather than abstract.
Choosing the Right Jinja2 Approach
| Scenario | Approach |
|---|---|
| Simple variable substitution | Template(string).render() inline |
| Multi-command config blocks | Template file + FileSystemLoader |
| Shared structure, vendor differences | Template inheritance |
| Repeated config pattern (ACL entries, neighbour blocks) | Macros |
| CIDR-to-vendor-format conversion | Custom filter |
Start simple. A flat template with loops and conditionals handles most network config use cases without needing inheritance or macros. Add those tools when you find yourself copy-pasting template blocks between files.
What’s Coming in Part 9
Part 9 covers REST APIs — the other major way to configure modern network devices. Where Netmiko speaks SSH and screen-scrapes CLI output, REST APIs speak HTTP and return JSON. We’ll use Python’s requests library to interact with vendor management APIs, and walk through a real example against the FortiManager REST API — directly relevant if you’re managing Fortinet environments at scale.
This post uses the same Containerlab topology as Parts 5–7. No new lab infrastructure required. Generated configs can be deployed to the lab using send_config_from_file() from Part 5.