Jinja2 Templates: Generate 100 Switch Configs from One Template

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 like for and if. 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

  1. Warm-up. Render the string "ip route 0.0.0.0 0.0.0.0 {{ gw }}" with gw="10.0.0.1".
  2. Loop. Given servers = ["10.0.0.10", "10.0.0.11"], use a {% for %} loop to render one ntp server <ip> line per address.
  3. Conditional. Write a template for an interface that includes the line ip helper-address 10.0.0.53 only when the variable dhcp_relay is true.
  4. File template. Move the interface template from exercise 3 into templates/intf.j2, load it with an Environment + FileSystemLoader (with trim_blocks on), and render it twice — once with the relay on, once off.
  5. Challenge. Build a template that renders a full OSPF block: router ospf {{ pid }} followed by one network {{ n.cidr }} area {{ n.area }} line for each entry in a networks list, and a passive-interface {{ i }} line for each interface in a passive list. 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.

Leave a Reply