Skip to content

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_blocks removes the first newline after a block tag.
  • lstrip_blocks strips 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 %}, and value | filter; the default filter and if/for handle optional fields and variable-length lists.
  • Enable trim_blocks and lstrip_blocks so block tags on their own lines don't leave stray whitespace; use an in-tag - only for surgical trimming.
  • Set undefined=StrictUndefined so a forgotten variable raises UndefinedError instead of silently shipping a blank into production config.
  • Ansible's template module runs the same engine and template file, but defaults trim_blocks on and lstrip_blocks off — set lstrip_blocks: true to 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.