9 min readbackend

Maud vs Tera, part 1: where your HTML actually comes from

Tera 2.0 landed in June 2026 and quietly broke the tidy 'compile-time vs runtime' story everyone tells about Rust templating. Part 1 of three: the two architectures, and what changed.

On this page

# A pinned dependency that says more than any benchmark

Open Zola’s Cargo.toml on master today and you’ll find this:

tera = { version = "1.17", features = ["preserve_order", "date-locale"] }

Zola is the static site generator this blog runs on. Tera is its template engine. Both were written by the same person — Vincent Prouillet, who created Tera in large part because Zola needed a template language its users could edit without touching Rust.

Tera 2.0.0 shipped on 26 June 2026. Three weeks later, the author of both projects has not migrated his own flagship application to his own new major version. That isn’t neglect, and it isn’t a knock on the release. It’s the most honest available data point about how big the v1→v2 break actually is — and it’s worth more than any migration guide, because the person best positioned to do the migration has looked at it and not done it yet.

This is part one of three on the two most architecturally opposed template engines in Rust. Part one is the architecture: what each one actually does to produce a string of HTML, and why Tera 2.0 makes the usual comparison out of date. Part two builds the same app twice. Part three is the decision framework and the measurements.

Two corrections, since they cost me time

Maud’s repository is lambda-fairy/maud and its docs live at maud.lambda.xyz. Several aggregator sites and AI-generated summaries cite insomnimus/maud and maud.lambda.cx; both are wrong. While checking release dates I also found search summaries reporting maud 0.27.0 as a February 2026 release. It’s February 2025. Primary sources only for anything load-bearing.


# Two answers to one question

Every server-rendered app answers the same question: where does the HTML come from? Maud and Tera answer it about as differently as two libraries can.

Maud makes HTML a Rust expression. The html! macro is a procedural macro that expands, at compile time, into ordinary Rust code that pushes strings into a buffer. There is no template file, no parser at runtime, no lookup by name. What you write is checked by rustc alongside everything else in the crate.

Tera makes HTML a runtime asset. Templates are text files. Tera parses them into an AST when you register them, caches that, and walks it per render against a Context of serialized data. The template is a thing your program loads, not a thing your program is.

flowchart TB
    subgraph M["Maud — compile time"]
        M1["html! macro in .rs"] --> M2["proc-macro expansion"]
        M2 --> M3["Rust string pushes"]
        M3 --> M4["HTML"]
    end
    subgraph T["Tera — runtime"]
        T1["dashboard.html"] --> T2["parse → AST"]
        T2 --> T3["cached in Tera instance"]
        T4["Context (serde)"] --> T5["walk AST"]
        T3 --> T5
        T5 --> T6["HTML"]
    end

    classDef ct fill:#059669,color:#fff,stroke:#065f46;
    classDef rt fill:#d97706,color:#fff,stroke:#92400e;
    class M1,M2,M3,M4 ct;
    class T1,T2,T3,T4,T5,T6 rt;

Everything else — the ergonomics, the failure modes, who on your team can edit a page — falls out of that one split.


# What the type system can and can’t reach

Here’s the difference in its most concrete form. In the demo app I built for part two, Priority is an enum with a couple of methods:

impl Priority {
    pub fn label(self) -> &'static str { /* "Low" | "Normal" | "High" */ }
    pub fn css_class(self) -> &'static str { /* "badge badge-low" | … */ }
}

In Maud, the template calls them:

span class=(task.priority.css_class()) { (task.priority.label()) }

That’s a method call inside markup. rustc checks it. Rename label() and the build breaks, pointing at this line.

Tera cannot do that. Data reaches a template by being serialized into a Context, and a Value has no methods. So the Tera version of the same app needs a view model:

#[derive(Serialize)]
struct TaskView {
    id: u32,
    title: String,
    done: bool,
    priority_label: &'static str,   // precomputed — templates can't call label()
    priority_class: &'static str,
    row_class: &'static str,
}

This isn’t a flaw. It’s the boundary doing its job: templates stay data-driven, which is exactly what lets a non-Rust-programmer edit them safely. But architecturally it’s the fork in the road. Maud collapses the gap between your domain model and your markup. Tera deliberately maintains it, and you pay for that in view models and clones.

The serialization boundary

Precisely: Tera renders against Context, a map of serde Values. Rust methods, trait impls and borrows don’t survive the crossing, so anything derived has to be computed before render and inserted as plain data.

In practice: a Tera template can only read fields somebody already put in a bag for it. Maud can reach back into your actual types and call things.


# Tera 2.0 moved the goalposts

The standard comparison goes: Maud catches template bugs at compile time, Tera catches them at runtime, pick your poison. That framing was accurate for Tera 1.x. It’s now partly wrong, and this is the part most existing write-ups haven’t caught up with.

Tera 2.0 is a rewrite from scratch, and several changes pull failure detection earlier:

Registered things are checked up front. Tera now verifies at template-add time that every filter, function, test and component a template references actually exists. In 1.x, a typo’d filter name sat quietly in a rarely-hit branch until a user found it. Now it fails when you register the template — startup, not midnight.

Undefined access is an error, not a shrug. {{ hey }} where hey doesn’t exist now errors instead of rendering empty. So does {{ existing.hey }} when hey is missing. Tera 1.x’s habit of silently rendering nothing — which turns a data bug into a blank spot on a page nobody notices for a week — is gone.

Macros are gone entirely. Replaced by components. The migration guide’s exact words are “Yep completely gone. Nada.” If your v1 templates lean on macros, that’s not a find-and-replace.

There’s a long tail of renames too: escapeescape_html, as_strstr, divisiblebydivisible_by, linebreaksbrnewlines_to_br. And my_vec.0 no longer works for indexing; it’s my_vec[0] now.

Don't overstate what Tera 2 gives you

Tera 2 validates that the things templates reference are registered, and errors on undefined access. That is genuinely earlier failure detection, and it closes a real gap.

It is not type-checking your data model. Nothing verifies that task.titel is a typo, or that the field you inserted is a string rather than a number, until that expression is evaluated. Maud’s guarantee — the whole template is Rust, checked by rustc — remains categorically stronger. The gap narrowed; it didn’t close.


# Where errors surface

The useful architectural axis isn’t “type safe or not.” It’s when a given class of mistake reaches you — and the answer differs by mistake, not just by engine.

flowchart LR
    C["COMPILE — cargo build<br/><br/>maud: typos, wrong types,<br/>renamed fields, bad markup"]
    S["STARTUP — registration<br/><br/>tera 1 + 2: parse errors<br/>tera 2: unknown filter/component"]
    F["FIRST RENDER of branch<br/><br/>tera 2: undefined<br/>variable access"]
    U["USER HITS IT<br/><br/>tera 1: undefined renders empty,<br/>bad filter in a cold branch"]
    C --> S --> F --> U

    classDef good fill:#059669,color:#fff,stroke:#065f46;
    classDef ok fill:#d97706,color:#fff,stroke:#92400e;
    classDef bad fill:#dc2626,color:#fff,stroke:#991b1b;
    class C good; class S ok; class F ok; class U bad;

For an architect this is the whole decision in one picture. Maud pushes nearly everything to the leftmost column. Tera 2 moved a meaningful set of failures from the rightmost column into the middle two. Tera 1.x left more of them at the right-hand end, which is where they cost the most.


# What each one is optimizing for

Maud’s design philosophy is that templates are code and should inherit the language’s tools rather than reimplement them. You get Rust’s control flow, Rust’s pattern matching, Rust’s borrow checker and Rust’s error messages, because it is Rust. The runtime library — including the framework integrations for axum, actix-web, Rocket and Poem — is around 100 lines. There’s almost nothing there at runtime because almost everything happened during compilation.

Tera’s philosophy is that templates are content, and content belongs to whoever edits content. Zola is the proof: its entire theme ecosystem exists because users can write templates without writing Rust. Take that away and Zola doesn’t work as a product. Tera’s runtime flexibility isn’t overhead somebody failed to optimize — it’s the feature, and Zola is what it’s for.

Which means the real architectural question is not “which is better.” It’s who edits this markup, and what happens when they get it wrong. Everything else is downstream of that.

Split by editor, not by benchmark

The most underused option is both, in the same binary. Maud for application UI and HTMX fragments, where engineers own the markup and compile-time checking pays. Tera for content-heavy or themeable pages, where someone else edits and hot reload matters. These are not competing choices at the project level — they’re the right answer for different surfaces of the same project.


# Putting it together

The classic framing — compile-time safety versus runtime flexibility — is still the right axis, but Tera 2.0 shifted the position of one endpoint. Tera now catches unregistered filters and components at startup and refuses to silently swallow undefined variables, which removes the two most annoying failure modes of 1.x without giving up the thing Tera exists for.

Maud’s guarantee is still categorically stronger, because it isn’t a validation pass — it’s the Rust compiler seeing your markup as code. The question is whether you want that badly enough to accept that changing a heading requires a rebuild, and that only Rust programmers can touch your HTML.

And that pinned tera = "1.17" in Zola is the note to end on. A major version being available is not the same as a major version being adopted, and the person with the most knowledge and the most incentive is still on 1.17. Factor migration cost into any plan that involves Tera 2, and get your estimate from the migration guide rather than the release announcement.

Next: part two builds the same task dashboard with both engines and diffs the output.


# References

views

Click to show appreciation

Discussion