Rendering device configuration with Jinja2¶
The source-of-truth article described a loop — pull → render → deploy → verify — and left the render step as a single word: turn structured data into device configuration, "typically with Jinja2 templates." This article opens up that step. You will learn how a template separates the parts of a config that vary from the parts that don't, how to keep stray whitespace out of the result, and how to render the same template from both plain Python and Ansible.
What templating solves¶
Device configurations are mostly repetition. Fifty switches differ by a hostname, a management IP, and a list of interfaces; the surrounding syntax is identical. Hand-maintaining fifty near-identical files — or fifty variable blocks that each restate the config structure — is exactly the drift the source of truth exists to prevent.
A template inverts that. You write the structure once, mark the spots that vary, and feed it data — ideally the same structured data your source of truth already holds. One template plus one dataset per device renders the full configuration. The template is the logic; the data is the truth; neither repeats the other.
The three constructs¶
Jinja2 (Pallets, currently 3.1.6) needs only three pieces of syntax for almost all config work:
{{ ... }}— expressions, substituted with a value.{% ... %}— statements:for,if,set, and so on.{{ value | filter }}— filters, which transform a value (default,upper,join, and many more).
Here is an interface stanza rendered for an arbitrary number of interfaces, with a sensible default and a conditional:
hostname {{ hostname }}
!
{% for intf in interfaces %}
interface {{ intf.name }}
description {{ intf.description | default("unused") }}
ip address {{ intf.ip }} {{ intf.mask }}
{% if intf.shutdown | default(false) %}
shutdown
{% else %}
no shutdown
{% endif %}
!
{% endfor %}
The data that drives it is ordinary structured data — the kind a NetBox query or a host_vars file
provides:
hostname: spine-01
interfaces:
- name: GigabitEthernet1
description: uplink-to-core
ip: 10.0.0.1
mask: 255.255.255.252
- name: GigabitEthernet2
ip: 10.0.0.5
mask: 255.255.255.252
shutdown: true
The second interface has no description, so default("unused") fills it; it sets shutdown: true, so the
if branch emits shutdown. Nothing about the template changes when a device gains a third interface — only
the data does.
Controlling whitespace¶
Whitespace is the detail that trips up every first Jinja2 template. A block tag like {% for %} sits on its
own line in the template for readability, but by default Jinja2 keeps the newline after the tag and the
indentation before it — so every loop iteration leaves a blank, indented line in the output. On an HTML
page nobody notices; in a CLI config it produces stray lines that can confuse a parser or a diff.
Two environment options fix this cleanly:
trim_blocksremoves the first newline after a block tag.lstrip_blocksstrips leading whitespace from the start of a line up to a block tag.
With both enabled, the template above renders exactly the config you would type by hand, with no blank lines
between stanzas. For the cases the options don't cover, add a hyphen inside a tag to strip adjacent
whitespace by hand: {%- ... %} trims before the tag, {% ... -%} trims after. Reach for the environment
options first and the manual - only where you need surgical control — it is easy to over-trim and run two
lines together.
Rendering from Python¶
In a standalone script, load the template from a directory, build an Environment with the whitespace
options set, and render it against your data. One option matters as much as the whitespace flags:
undefined=StrictUndefined. By default, a reference to a variable you forgot to supply renders as an
empty string and the bad config ships silently; StrictUndefined instead raises
UndefinedError at render time, turning a typo into a
loud failure before anything reaches a device.
# render.py — turn a per-device data file into a config, failing loud on gaps
import yaml
from jinja2 import Environment, FileSystemLoader, StrictUndefined
env = Environment(
loader=FileSystemLoader("templates"),
trim_blocks=True,
lstrip_blocks=True,
keep_trailing_newline=True, # don't drop the file's final newline
undefined=StrictUndefined, # a missing variable is an error, not a blank
)
with open("vars/spine-01.yml") as f:
data = yaml.safe_load(f)
template = env.get_template("interfaces.j2")
print(template.render(**data))
Autoescaping stays off here, which is Jinja2's default. Autoescape exists to make HTML and XML output safe by
converting characters like < and &; applied to a CLI config it would corrupt the very text you are trying
to emit, so leave it disabled for configuration rendering and enable it only when you are producing markup.
The same template in Ansible¶
Ansible's template
module runs the identical Jinja2 engine, so the .j2 file above works unchanged. The difference is that
Ansible sources variables from inventory — group_vars, host_vars, or a dynamic inventory pulled live from
the source of truth — rather than a file you open yourself.
- name: Render device configs from source-of-truth data
hosts: all
gather_facts: false
tasks:
- name: Build config from inventory variables
ansible.builtin.template:
src: templates/interfaces.j2
dest: "rendered/{{ inventory_hostname }}.cfg"
lstrip_blocks: true # trim_blocks is already on by default in Ansible
delegate_to: localhost
Watch the whitespace defaults: Ansible turns trim_blocks on by default but leaves lstrip_blocks off, so
a template that rendered cleanly in your Python script can gain indented blank lines under Ansible until you
set lstrip_blocks: true. Rendering to a file with delegate_to: localhost also lets you review the output
and run it through a linter or a dry run before any deploy step touches hardware.
Keep logic out of the template¶
A template is for presentation, not computation. If you find yourself writing nested conditionals, arithmetic,
or data massaging inside the .j2 file, do that work in Python or in the source-of-truth model and pass the
template clean, final values. Templates that only substitute and iterate stay readable and reviewable; the
moment business logic creeps in, they become the hardest part of the pipeline to debug. Pair that discipline
with StrictUndefined and a validation pass over the rendered output — rendering is still code generation,
and the rendered config deserves the same review as any other artifact before it is deployed.
Key takeaways¶
- Templating separates the structure of a config (the template) from the data that varies per device (ideally your source of truth), so neither repeats the other.
- Three constructs cover almost all config work:
{{ expression }},{% statement %}, andvalue | filter; thedefaultfilter andif/forhandle optional fields and variable-length lists. - Enable
trim_blocksandlstrip_blocksso block tags on their own lines don't leave stray whitespace; use an in-tag-only for surgical trimming. - Set
undefined=StrictUndefinedso a forgotten variable raisesUndefinedErrorinstead of silently shipping a blank into production config. - Ansible's
templatemodule runs the same engine and template file, but defaultstrim_blockson andlstrip_blocksoff — setlstrip_blocks: trueto match a Python render. - Keep logic out of templates; compute values upstream, render to a file, and validate before you deploy.
Sources: Jinja2 documentation (Pallets),
Jinja2 Template Designer — whitespace control,
Jinja2 API — undefined types,
Jinja2 on PyPI,
Ansible ansible.builtin.template module.