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:

DelimiterPurpose
{{ 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:

VariableValue
loop.indexCurrent iteration (1-based)
loop.index0Current iteration (0-based)
loop.firstTrue on first iteration
loop.lastTrue on last iteration
loop.lengthTotal 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

ScenarioApproach
Simple variable substitutionTemplate(string).render() inline
Multi-command config blocksTemplate file + FileSystemLoader
Shared structure, vendor differencesTemplate inheritance
Repeated config pattern (ACL entries, neighbour blocks)Macros
CIDR-to-vendor-format conversionCustom 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.