Python Generating Configs with Jinja2

Python Generating Configs with Jinja2
In: Python-Intro
Table of Contents

If you have ever rolled out a batch of new sites or devices, I hope you can relate to the pain. The config is more or less the same each time, but the hostnames, IPs, and VLANs change. You copy the config from the last site, tweak the values, save it, and move on. It works, but it does not scale, and it is easy to make mistakes.

The way most of us solve this is with templates. You write the config once with placeholders where the values go, and you fill in the placeholders each time you need a new device config. Jinja2 is the tool we use for this in Python. It takes a template, some data, and gives you back a finished config.

In this post, I will walk you through how Jinja2 works, cover the syntax you actually need, and we will end up generating the same config for Cisco IOS, Juniper Junos, and Arista EOS from the same data. We are only going to generate the configs as text in this post. Pushing them to devices is something you already know how to do from the earlier posts.

Installing Jinja2

Jinja2 is not built into Python, so we install it with pip or uv.

uv add jinja2
pip install jinja2

That is it. Once installed, we can start writing templates.

Your First Template

Let's start with the simplest possible example. A Jinja2 template is just a string with placeholders in it. Placeholders go in double curly braces like {{ variable_name }}. When you render the template with some data, Jinja2 swaps the placeholders for the actual values.

from jinja2 import Template

template = Template("hostname {{ device_name }}")
output = template.render(device_name="r1")

print(output)

Output

hostname r1

That is it. Four lines of code and we have generated our first bit of config from a template. Let's break down what happened.

Template("...") creates a Jinja2 template object from a string. That string has {{ device_name }} as a placeholder.

.render(device_name="r1") fills in the placeholder with the value we pass in. The name of the argument (device_name) has to match the placeholder in the template. If you pass a value with a different name, Jinja2 will just leave the placeholder empty.

The result comes back as a plain string. We can print it, save it to a file, or send it to a device with Netmiko.

You can also pass in multiple values at once.

from jinja2 import Template

template = Template("""
hostname {{ device_name }}
!
interface {{ interface }}
 ip address {{ ip }} {{ mask }}
""")

output = template.render(
    device_name="r1",
    interface="Ethernet0/0",
    ip="192.168.1.1",
    mask="255.255.255.0"
)

print(output)

Output

hostname r1
!
interface Ethernet0/0
 ip address 192.168.1.1 255.255.255.0

The template is now a full block of config. We use triple-quoted strings so we can include newlines directly. Every {{ ... }} in the template gets replaced with the matching value from .render().

If you have not seen this before, the """...""" (or '''...''') is called a triple-quoted string. It works just like a normal string, but it can span multiple lines and preserve line breaks exactly as you write them. Regular single or double quotes have to stay on one line, so if we tried to write the template as "hostname ...\n interface ...\n ...", we would need to add \n everywhere to get newlines. Triple-quoted strings let us write the config the way we would actually see it on a device, which is much easier to read and edit.

Loops

Once you have a config that repeats, like a list of VLANs or interfaces, you do not want to write out every entry by hand. Jinja2 has a {% for %} block that lets you loop through a list and generate one line per item.

The syntax is {% for item in list %} and {% endfor %} to close the block. Anything between them gets rendered once per item.

from jinja2 import Template

template = Template("""
{% for vlan in vlans %}
vlan {{ vlan.id }}
 name {{ vlan.name }}
{% endfor %}
""")

vlans = [
    {"id": 10, "name": "USERS"},
    {"id": 20, "name": "SERVERS"},
    {"id": 30, "name": "MGMT"}
]

output = template.render(vlans=vlans)
print(output)

Output



vlan 10
 name USERS

vlan 20
 name SERVERS

vlan 30
 name MGMT

It works, but the output has extra blank lines. That is because Jinja2 preserves the whitespace and newlines around the {% for %} and {% endfor %} tags. Every time the loop iterates, it also renders the newlines around the tags, which is what creates the gaps.

We can fix this using whitespace control, which is a small tweak to the tag syntax. Adding a minus sign after the opening {% or before the closing %} tells Jinja2 to strip the whitespace on that side.

from jinja2 import Template

template = Template("""
{% for vlan in vlans -%}
vlan {{ vlan.id }}
 name {{ vlan.name }}
{% endfor -%}
""")

vlans = [
    {"id": 10, "name": "USERS"},
    {"id": 20, "name": "SERVERS"},
    {"id": 30, "name": "MGMT"}
]

output = template.render(vlans=vlans)
print(output)

Output


vlan 10
 name USERS
vlan 20
 name SERVERS
vlan 30
 name MGMT

Much cleaner. The dash (-) inside the tag is called a whitespace control marker, and it tells Jinja2 to strip whitespace on the side where the dash is.

  • -%} strips whitespace after the tag, including the newline that follows it
  • {%- strips whitespace before the tag, including any newlines above it
  • You can use both, like {%- for vlan in vlans -%}, to strip whitespace on both sides (not ideal in this example).

For example, {% for vlan in vlans -%} keeps everything before the tag but removes the newline right after it. That is why the loop no longer produces blank lines between each iteration.

Access List of Values

Notice we used vlan.id and vlan.name inside the loop, because each item is a dictionary. Jinja2 lets you use dot notation to access dictionary keys, which reads a bit nicer than the Python ["id"] syntax. You can also use vlan["id"] if you prefer, both work.

You can also loop through a plain list of values.

from jinja2 import Template

template = Template("""
{% for interface in interfaces -%}
interface {{ interface }}
 no shutdown
{% endfor -%}
""")

interfaces = ["Ethernet0/0", "Ethernet0/1", "Ethernet0/2"]

output = template.render(interfaces=interfaces)
print(output)

Output


interface Ethernet0/0
 no shutdown
interface Ethernet0/1
 no shutdown
interface Ethernet0/2
 no shutdown

Conditionals

Sometimes you only want to include a piece of config if a certain condition is met. Maybe you only want to add an OSPF block if OSPF is enabled on the device, or you only want to include a description on an interface if one is provided. Jinja2 has an {% if %} block for exactly this.

The syntax is {% if condition %} followed by {% endif %} to close the block. Anything between them is only rendered when the condition is true.

from jinja2 import Template

template = Template("""
hostname {{ hostname }}
{% if snmp_enabled %}
snmp-server community public RO
snmp-server location {{ location }}
{% endif %}
""")

output = template.render(
    hostname="r1",
    snmp_enabled=True,
    location="London"
)

print(output.strip())

Output

hostname r1

snmp-server community public RO
snmp-server location London

If we render the same template with snmp_enabled=False, the SNMP block is skipped entirely.

output = template.render(hostname="r1", snmp_enabled=False)
print(output.strip())

Output

hostname r1

if/else

You can add an {% else %} branch that runs when the condition is false. This is handy when you want to output something either way, but with different content depending on the condition.

from jinja2 import Template

template = Template("""
{% if is_uplink %}
description Uplink-to-core
switchport mode trunk
{% else %}
description Access-port
switchport mode access
{% endif %}
""")

output = template.render(is_uplink=True)
print(output.strip())

Output

description Uplink-to-core
switchport mode trunk

And with is_uplink=False

description Access-port
switchport mode access

if/elif/else

You can chain multiple conditions with {% elif %}.

from jinja2 import Template

template = Template("""
{% if role == "core" %}
description CORE-ROUTER
{% elif role == "edge" %}
description EDGE-ROUTER
{% else %}
description ACCESS-SWITCH
{% endif %}
""")

output = template.render(role="edge")
print(output.strip())

Output

description EDGE-ROUTER

Same idea as if/elif/else in Python. Jinja2 checks each condition from top to bottom and runs the first one that matches.

Truthy Values

You can also use {% if %} with just a variable name. Jinja2 treats empty strings, empty lists, None, and 0 as false, and everything else as true. This is the same truthy/falsy behaviour we covered previously in this series.

from jinja2 import Template

template = Template("""
interface {{ interface }}
{% if description %} description {{ description }}
{% endif -%}
""")

output = template.render(interface="Ethernet0/0", description="")
print(output.strip())
Notice we can also write it this way, with the {% if %} tag and the config line on the same line. That keeps the leading space intact for indentation and avoids extra blank lines in the output.

Output

interface Ethernet0/0

Because description is an empty string, the if block is skipped. Pass a real description and the line will show up.

output = template.render(interface="Ethernet0/0", description="Uplink-to-core")

Output

interface Ethernet0/0
 description Uplink-to-core

Filters

Filters are transformations you can apply to values inside a template. You use the pipe symbol | to send a value through a filter, and the filter modifies it before it gets rendered. This is really useful for small tweaks like changing case, providing defaults, or joining lists.

The syntax is {{ value | filter_name }}.

upper, lower, capitalize

Let's start with case filters. Say you always want your hostnames in uppercase in the config.

from jinja2 import Template

template = Template("hostname {{ hostname | upper }}")

print(template.render(hostname="r1"))

Output

hostname R1

upper converts the value to uppercase. lower does the opposite, and capitalize capitalises only the first letter.

default

default gives you a fallback value if the variable is empty or missing.

from jinja2 import Template

template = Template("description {{ description | default('No description', true) }}")

print(template.render(description=""))
print(template.render())

Output

description No description
description No description

One thing worth knowing. By default, this filter only triggers when the variable is completely missing (never passed in). If you pass in an empty string, default treats it as a real value and does not fall back. Adding true as a second argument tells the filter to also use the default for any falsy value, empty strings, empty lists, zero, or None.

If you pass a real value, the filter is ignored.

print(template.render(description="Uplink-to-core"))

Output

description Uplink-to-core

join

join takes a list and combines the items into a single string, using whatever separator you pass in.

from jinja2 import Template

template = Template("ntp servers: {{ ntp_servers | join(', ') }}")

ntp_servers = ["10.0.0.1", "10.0.0.2", "10.0.0.3"]
print(template.render(ntp_servers=ntp_servers))

Output

ntp servers: 10.0.0.1, 10.0.0.2, 10.0.0.3

length

length gives you the number of items in a list (or characters in a string). Useful when you want to make a decision based on how many of something there are.

from jinja2 import Template

template = Template("Total VLANs: {{ vlans | length }}")

vlans = [10, 20, 30, 40]
print(template.render(vlans=vlans))

Output

Total VLANs: 4

Chaining Filters

You can chain multiple filters together, and Jinja2 applies them left to right.

from jinja2 import Template

template = Template("hostname {{ hostname | default('unknown') | upper }}")

print(template.render())

Output

hostname UNKNOWN

Here we fall back to "unknown" first, then uppercase the result. You could easily add more filters on the end if you needed to.

Jinja2 has a lot more filters built in, replace, trim, sort, unique, first, last, round, and more. You can also write your own custom filters, though we will not cover that here.

Loading Templates from Files

Everything we have written so far has kept the template as a string right inside the Python script. That is fine for tiny examples, but it does not scale. Real templates for a full device config can be dozens or hundreds of lines long, and mixing that with Python code makes both harder to read.

The proper way to use Jinja2 is to keep templates in their own files, usually with a .j2 extension, in a folder alongside your script. Your Python code loads the template, feeds it data, and renders the result. This keeps things clean, and it means someone editing the template does not need to touch the Python code, and vice versa.

Here is what your project structure might look like.

my_project/
├── main.py
└── templates/
    └── cisco_ios.j2

Let's write the template first. Save this as templates/cisco_ios.j2.

hostname {{ hostname }}
!
{% for vlan in vlans %}
vlan {{ vlan.id }}
 name {{ vlan.name }}
{% endfor %}
!
{% for interface in interfaces %}
interface {{ interface.name }}
 description {{ interface.description }}
 ip address {{ interface.ip }} {{ interface.mask }}
{% endfor %}

Now the Python script that loads and renders it.

from jinja2 import Environment, FileSystemLoader

env = Environment(
    loader=FileSystemLoader("templates"),
    trim_blocks=True,
    lstrip_blocks=True
)

template = env.get_template("cisco_ios.j2")

data = {
    "hostname": "r1",
    "vlans": [
        {"id": 10, "name": "USERS"},
        {"id": 20, "name": "SERVERS"}
    ],
    "interfaces": [
        {"name": "Ethernet0/0", "description": "Uplink to core", "ip": "192.168.1.1", "mask": "255.255.255.0"},
        {"name": "Ethernet0/1", "description": "LAN", "ip": "10.0.0.1", "mask": "255.255.255.0"}
    ]
}

output = template.render(data)
print(output)

Output

hostname r1
!
vlan 10
 name USERS
vlan 20
 name SERVERS
!
interface Ethernet0/0
 description Uplink to core
 ip address 192.168.1.1 255.255.255.0
interface Ethernet0/1
 description LAN
 ip address 10.0.0.1 255.255.255.0

Environment and FileSystemLoader

Environment is Jinja2's main class for anything beyond the simple Template we have been using. It holds all the settings for how templates should be loaded and rendered. FileSystemLoader tells the environment where to look for template files, in this case, the templates folder relative to the script.

env.get_template("cisco_ios.j2") loads the file and returns a template object, which you render exactly the same way as before with .render().

trim_blocks and lstrip_blocks

Remember earlier when we had to add -%} all over our templates to strip whitespace? These two options are the proper fix.

  • trim_blocks=True - Removes the first newline that appears immediately after a template tag like {% for %} or {% if %}.
  • lstrip_blocks=True - Strips any leading spaces and tabs on a line before a template tag, so you can indent your tags naturally without that indentation showing up in the output.

Also notice how we passed the data. Instead of listing every variable as a keyword argument like template.render(hostname="r1", vlans=..., interfaces=...), we bundled everything into a dictionary and passed that. Jinja2 accepts either style.

Loading Data from YAML

Right now the data is hardcoded in the Python script as a dictionary. That works, but the whole point of templates is to separate the code from the data.

We covered YAML earlier in the series, you can save the following as data.yml.

hostname: r1
vlans:
  - id: 10
    name: USERS
  - id: 20
    name: SERVERS
interfaces:
  - name: Ethernet0/0
    description: Uplink to core
    ip: 192.168.1.1
    mask: 255.255.255.0
  - name: Ethernet0/1
    description: LAN
    ip: 10.0.0.1
    mask: 255.255.255.0

Then update the Python script to load the data from this file instead of defining it inline.

import yaml
from jinja2 import Environment, FileSystemLoader

env = Environment(
    loader=FileSystemLoader("templates"),
    trim_blocks=True,
    lstrip_blocks=True
)

template = env.get_template("cisco_ios.j2")

with open("data.yml") as file:
    data = yaml.safe_load(file)

output = template.render(data)
print(output)

Cleaning Up the Cisco Config Style

Let's also make the output look more like a real Cisco config, with ! separators between sections. Update the template file to add them.

hostname {{ hostname }}
!
{% for vlan in vlans %}
vlan {{ vlan.id }}
 name {{ vlan.name }}
!
{% endfor %}
{% for interface in interfaces %}
interface {{ interface.name }}
 description {{ interface.description }}
 ip address {{ interface.ip }} {{ interface.mask }}
!
{% endfor %}

Output

hostname r1
!
vlan 10
 name USERS
!
vlan 20
 name SERVERS
!
interface Ethernet0/0
 description Uplink to core
 ip address 192.168.1.1 255.255.255.0
!
interface Ethernet0/1
 description LAN
 ip address 10.0.0.1 255.255.255.0
!

The ! inside the loops runs once per item, so you get a separator after every VLAN and every interface. That looks a lot closer to what a real Cisco device would show if you ran show running-config.

A Multi-Vendor Example

Let's put it all together. We are going to generate the same logical config for three different vendors, Cisco IOS, Juniper Junos, and Arista EOS, all from the same YAML data.

For simplicity, we will focus on a single common thing across all three, an NTP server. Same idea applies to any part of the config.

The Data

Save this as data.yml.

hostname: r1
ntp_servers:
  - 10.0.0.1
  - 10.0.0.2

The Templates

Save each of these in the templates/ folder.

templates/cisco_ios.j2

hostname {{ hostname }}
!
{% for server in ntp_servers %}
ntp server {{ server }}
{% endfor %}

templates/junos.j2

set system host-name {{ hostname }}
{% for server in ntp_servers %}
set system ntp server {{ server }}
{% endfor %}

templates/arista_eos.j2

hostname {{ hostname }}
!
{% for server in ntp_servers %}
ntp server {{ server }}
{% endfor %}

The Script

Now the script that ties it all together. We loop through a list of vendors, pick the right template for each, and render it with the same data.

import yaml
from jinja2 import Environment, FileSystemLoader

env = Environment(
    loader=FileSystemLoader("templates"),
    trim_blocks=True,
    lstrip_blocks=True
)

with open("data.yml") as file:
    data = yaml.safe_load(file)

vendors = ["cisco_ios", "junos", "arista_eos"]

for vendor in vendors:
    template = env.get_template(f"{vendor}.j2")
    output = template.render(data)

    filename = f"{data['hostname']}_{vendor}.cfg"
    with open(filename, "w") as file:
        file.write(output)

    print(f"Generated {filename}")

Output

Generated r1_cisco_ios.cfg
Generated r1_junos.cfg
Generated r1_arista_eos.cfg

r1_cisco_ios.cfg

hostname r1
!
ntp server 10.0.0.1
ntp server 10.0.0.2

r1_junos.cfg

set system host-name r1
set system ntp server 10.0.0.1
set system ntp server 10.0.0.2

r1_arista_eos.cfg

hostname r1
!
ntp server 10.0.0.1
ntp server 10.0.0.2

Where This Goes Next

From here, you could take this in a bunch of directions. Load a full inventory of devices from YAML and generate a config file per device. Push each generated config to the corresponding device with Netmiko or NAPALM. Use API and connect to NetBox so the data comes from your source of truth automatically.

Every one of those steps uses skills you have already learned in this series, YAML, loops, Netmiko, error handling, and so on. Jinja2 slots into all of it as the way you turn structured data into device-ready config.

Written by
Suresh Vinasiththamby
Tech enthusiast sharing Networking, Cloud & Automation insights. Join me in a welcoming space to learn & grow with simplicity and practicality.
Comments
More from Packetswitch
Great! You’ve successfully signed up.
Welcome back! You've successfully signed in.
You've successfully subscribed to Packetswitch.
Your link has expired.
Success! Check your email for magic link to sign-in.
Success! Your billing info has been updated.
Your billing was not updated.