Back
Technical Essay

The Template Trap

How network configuration templates silently evolve into unstructured, untested code, and why the escape path leads to native programming languages.

The promise

Templates for router and switch configuration are one of the first tools network teams reach for when they begin automating. The appeal is obvious: write the configuration once as a template, inject device-specific variables, and render consistent configs across hundreds or thousands of devices. Jinja2, Go templates, Mako, ERB. The options are plentiful and the learning curve feels gentle.

For simple cases, templates deliver on their promise. A handful of variables, a few conditional blocks, and the result is a repeatable configuration that eliminates copy-paste errors. The trouble starts when "simple" stops being an accurate description of your network.


The complexity conundrum

Networks grow. They diversify across vendors, hardware generations, protocol stacks, and deployment contexts. Each new requirement adds logic to the template: another conditional, another loop, another variable interpolation. What began as a readable configuration snippet gradually becomes a program in disguise.

Consider a template that must handle both Cisco and Juniper devices, configure OSPF and BGP conditionally, and iterate over interfaces with vendor-specific syntax:

Jinja2 Multi-vendor configuration template
{# Variable declarations #}
{% set vendors = ["cisco", "juniper"] %}
{% set ospf_enabled = True %}
{% set bgp_enabled = True %}

{% for device in devices %}
# Configuration for {{ device.hostname | upper }}
{% if device.vendor == "cisco" %}
hostname {{ device.hostname }}
{% for interface in device.interfaces %}
interface {{ interface.name }}
 description {{ interface.desc }}
 ip address {{ interface.ip }}
 no shutdown
{% endfor %}
{% if ospf_enabled %}
router ospf 1
{% for interface in device.interfaces %}
 network {{ interface.ip.split('/')[0] }} 0.0.0.255 area 0
{% endfor %}
{% endif %}
{% if bgp_enabled %}
router bgp 65001
 bgp router-id {{ device.interfaces[0].ip.split('/')[0] }}
 neighbor 192.0.2.1 remote-as 65002
{% endif %}
{% elif device.vendor == "juniper" %}
set system host-name {{ device.hostname }}
{% for interface in device.interfaces %}
set interfaces {{ interface.name }} description "{{ interface.desc }}"
set interfaces {{ interface.name }} unit 0 family inet address {{ interface.ip }}
{% endfor %}
{% if ospf_enabled %}
set protocols ospf area 0.0.0.0 interface all
{% endif %}
{% if bgp_enabled %}
set protocols bgp group external type external
set protocols bgp group external neighbor 192.0.2.1 peer-as 65002
set protocols bgp group external export ["advertise-routes"]
{% endif %}
{% endif %}
# End of configuration for {{ device.hostname }}
{% endfor %}

This is no longer a template. It is a program written in a language that has no compiler, no type system, no debugger, and no test framework. Every {% if %} is a branch. Every {% for %} is a loop. Every .split('/')[0] is a string manipulation that will fail silently if the input format changes. The template has all the complexity of code with none of the safety nets.

The trap

Templates become code the moment they contain conditionals and loops. But they never gain the tooling, the testing, or the discipline that code demands. The result is unstructured, untested logic governing production infrastructure.


Four problems that compound

🧩

Logic overload

Conditional statements, loops, and variable interpolations multiply to accommodate vendor differences, protocol variations, and deployment contexts. Engineers struggle to predict how a change in one branch affects the rendered output. Misconfigurations become inevitable.

🔍

Readability decay

Complex templates are notoriously difficult to read and maintain. When a template resembles code more than a configuration snippet, it demands programming rigor: the very rigor that the template structure was designed to avoid. The logic becomes convoluted, difficult to understand, and fragile to change over time.

🚫

No testing framework

Templates effectively become code as they incorporate programming constructs, yet they lack robust testing practices. Unit tests, integration tests, and code reviews are seldom applied. Bugs hide until production, where a minor template change can cascade into a network outage.

📦

Poor composition

Templates suffer from inadequate structuring. There is minimal use of modularization, where smaller reusable components build up the overall configuration. Monolithic templates are brittle: a change for one scenario may require modifications throughout the entire template.


The illusion of code without testing

Perhaps the most dangerous aspect of the template trap is the testing gap. In software engineering, the relationship between code and tests is non-negotiable. Every function has a unit test. Every integration point has a contract test. Every deployment goes through a CI/CD pipeline that validates correctness before reaching production.

Templates exist outside this discipline. They are rendered, not compiled. They produce text, not typed structures. There is no natural place to write a unit test for a Jinja2 block. There is no linter that understands the semantics of a network configuration. There is no debugger that lets you step through a template's execution path while observing the state of each variable.

If your template contains conditionals and loops, it is code. If it is code, it needs tests. If you cannot test it, you should not be running it in production.


The missing tool: feature flags

In software development, feature flags allow teams to enable or disable features without deploying new code, facilitating gradual rollouts and A/B testing. In the realm of network templates, implementing feature flags is cumbersome at best and dangerous at worst.

Without easy mechanisms for progressive rollouts, deploying new network features becomes an all-or-nothing proposition. A change to a shared template affects every device that references it, simultaneously. There is no way to roll out a new BGP policy to 5% of routers first and watch for regressions before widening the blast radius.

Blast radius

A single error in a shared template propagates to every device that consumes it. Without feature flags or progressive rollout mechanisms, the blast radius of a mistake is the entire fleet.


The escape: structured configuration in native code

The alternative is not to abandon automation. It is to stop pretending that templates are not code and instead write actual code with the full power and discipline of a native programming language: Go, Python, Rust, Java, or any language with a compiler, a type system, a test framework, and a debugger.

Consider the same multi-vendor configuration expressed as structured code:

Go Structured configuration builder
for _, device := range devices {
    scc.SetComment(fmt.Sprintf(
        "Configuration for %s: %s",
        strings.ToUpper(device.Vendor), device.Hostname,
    ))

    // General settings
    scc.Setf("hostname %s", device.Hostname)

    // Interfaces
    for _, iface := range device.Interfaces {
        scc.PushSection(fmt.Sprintf("interface %s", iface.Name)).
            Setf("description %s", iface.Description).
            Setf("ip address %s", iface.IP)
        if device.Vendor == "cisco" {
            scc.Set("no shutdown")
        }
        scc.Pop() // Ends interface section
    }

    // OSPF
    if device.OSPFEnabled {
        scc.PushSection("router ospf 1")
        for _, iface := range device.Interfaces {
            if device.Vendor == "cisco" {
                scc.Setf("network %s 0.0.0.255 area 0",
                    strings.Split(iface.IP, " ")[0])
            } else if device.Vendor == "juniper" {
                scc.PushSection("protocols ospf").
                    Set("area 0.0.0.0 interface all").
                    Pop()
            }
        }
        scc.Pop() // Ends OSPF
    }

    // BGP
    if device.BGPEnabled {
        if device.Vendor == "cisco" {
            scc.PushSection("router bgp 65001").
                Setf("bgp router-id %s", device.BGPRouterID).
                Setf("neighbor %s remote-as %d",
                    device.BGPNeighbor, device.BGPRemoteAS).
                Pop()
        } else if device.Vendor == "juniper" {
            scc.PushSection("protocols bgp group external").
                Set("type external").
                Setf("neighbor %s peer-as %d",
                    device.BGPNeighbor, device.BGPRemoteAS).
                Set(`export ["advertise-routes"]`).
                Pop()
        }
    }

    scc.SetComment(fmt.Sprintf(
        "End of configuration for %s", device.Hostname,
    ))
}

The logic is identical. The output is the same. But every dimension of the engineering experience has changed.

What native code gives you

Compiler and type systemErrors are caught at build time, not in production. A mistyped field name is a compilation failure, not a silent misconfiguration.
Unit and integration testingEvery configuration function can be tested in isolation. Feed it a device struct, assert the rendered output. Run it in CI on every commit.
Modularity and compositionFunctions, methods, and interfaces enable genuine code reuse. Build an OSPF module once, compose it into any device configuration.
Static analysis and lintingCatch unreachable branches, unused variables, and potential nil dereferences before the code ever runs.
Native debuggerStep through the configuration logic line by line, inspect variable state, and understand exactly what is being rendered and why.
Feature flags and progressive rolloutImplement feature toggles programmatically. Roll out a new policy to 5% of the fleet, observe, then widen. Roll back with a flag flip, not a revert.

The structural comparison

TEMPLATE APPROACH Monolithic template file Text rendering (no types) Manual review only All-or-nothing deployment NATIVE CODE APPROACH Modular functions + packages Typed structs + compiler Unit tests + CI/CD pipeline Feature flags + progressive rollout
Template approach vs. structured native code

Investing in developer practices

Moving from templates to native code is not just a technical shift. It is a cultural one. Network teams must adopt software development best practices: code reviews where peers catch errors early, CI/CD pipelines that test and deploy changes safely, and documentation that aids understanding and onboarding.

This is not asking network engineers to become software developers. It is recognizing that the moment configuration management involves conditionals, loops, and multi-vendor logic, it is software development. The choice is between doing it with the right tools or doing it without them.

The path forward

The goal is not to abandon templates entirely. Simple, logic-free templates still have their place. The goal is to recognize the moment a template crosses the threshold into code, and to treat it accordingly: with a compiler, a test suite, a debugger, and the discipline of software engineering.


The better answer: model-driven configuration

Structured configuration in native code is a significant step forward, but the real destination is to stop writing configuration logic altogether and instead derive it from a model. This is exactly what YANG was designed to do.

YANG (RFC 7950) is a data modeling language for network configuration and state. Rather than writing templates or imperative code that renders CLI commands, you define a schema: the structure, types, constraints, and relationships of the configuration data. The device then consumes structured data (XML or JSON) that conforms to that schema via NETCONF or RESTCONF. No string manipulation, no vendor-specific syntax, no text rendering.

YANG inverts the problem. Instead of "how do I render this configuration," the question becomes "what is the shape of the data the device expects." The model defines the contract. The data conforms to it. The device interprets it.

The promise of YANG is substantial. OpenConfig, a vendor-neutral initiative, has built a library of YANG models for common network functions: interfaces, BGP, OSPF, MPLS, QoS. The IETF has standardized NETCONF (RFC 6241) and RESTCONF (RFC 8040) as the transport protocols. In principle, the same structured data should configure any compliant device regardless of vendor.

Why YANG has not won yet

In practice, the YANG ecosystem has struggled to reach critical mass, and the primary reason is tooling. The models exist. The protocols exist. What is missing is the developer experience that makes them productive to use at scale.

🛠

Tooling gap

There is no mature, widely adopted YANG toolchain comparable to what software developers expect: no great IDE integration, limited code generation, rudimentary validation libraries. Working with YANG models often feels like working against them.

🔀

Vendor deviations

Despite OpenConfig's efforts, vendors implement YANG models inconsistently. Augmentations, deviations, and proprietary extensions fragment the "universal standard" promise. The same BGP configuration requires different YANG paths on different platforms.

📚

Model coverage

OpenConfig and IETF models cover common features well, but advanced or vendor-specific functionality often falls outside their scope. Teams end up maintaining a mix of standard and native models, losing the consistency benefit.

🧪

Testing and simulation

Testing YANG-based configurations against a model is possible in theory, but the tooling for offline validation, simulation, and dry-run execution remains immature compared to what a native language's test framework provides.

The irony is that YANG solves the right problem. It moves configuration from imperative rendering to declarative data. It separates the "what" (the configuration intent) from the "how" (the device-specific implementation). It is, conceptually, the ideal bridge between templates and true model-driven infrastructure. But without a first-class developer experience, adoption stalls, and teams fall back to templates or native code because those tools, however imperfect, at least work reliably today.

The lesson

A good data model without good tooling is an academic exercise. OpenConfig and the IETF have done the hard work of defining the models. What the industry still lacks is the tooling ecosystem that makes those models productive, testable, and pleasant to work with at scale.


The full picture

This shift connects directly to the broader evolution described in Infrastructure as Code, Infrastructure as Data. Templates are generation one: config file rendering. Structured configuration in native code is generation two: actual Infrastructure as Code. YANG and model-driven configuration point toward generation three: Infrastructure as Data. The next generation, Intent-Driven Infrastructure, goes further still, from declarative models to autonomous systems that converge the network toward a declared intent. But the first step is escaping the template trap.