Maud vs Tera, part 3: the numbers, and how to actually choose
Maud renders 14-17x faster than Tera 2.0. That's also the least useful fact in this post — the absolute delta is ~15 microseconds. Benchmarks, escaping, ecosystem health, and a decision framework that doesn't hinge on either.
On this page
# The number, and why it’s a trap
I benchmarked the two dashboards from part two. Same page, same data, same output, release build, 200,000 iterations after a 1,000-render warmup:
| Engine | ns per render | Output size |
|---|---|---|
| Maud 0.27.0 | ~900–1,140 | 1,919 B |
| Tera 2.0.0 | ~15,400–15,950 | 1,990 B |
Maud is roughly 14–17× faster. If you want a number to win an argument with, there it is.
Now here’s why you shouldn’t use it. The absolute difference is about 14.6 microseconds per render. A single Postgres round trip on a warm connection is somewhere between 500 and 2,000 microseconds. So on any page backed by a database — which is to say, the page you’re actually building — swapping template engines moves something like 1–3% of request time.
The ratio is real. It’s also measuring the one part of your request that was never the problem.
Intel Core i5-5287U @ 2.90 GHz (a 2015 laptop CPU), cargo build --release, rustc 1.88.0, 200k iterations with std::hint::black_box on the result. Absolute numbers will be considerably better on modern hardware; the ratio is the part that transfers.
Maud varied 909–1,143 ns across runs — about 25% — so treat it as a range, not a figure. Tera was stable at 15,410–15,947 ns. And this measures rendering only: no I/O, no database, no HTTP. That’s deliberately the most flattering possible framing for the compiled engine, and it still only buys ~15 µs.
flowchart LR
subgraph A["Same request, rendered with maud"]
A1["Postgres round trip<br/>500-2000 µs"] --> A2["render<br/>~1 µs"]
end
subgraph B["Same request, rendered with tera 2.0"]
B1["Postgres round trip<br/>500-2000 µs"] --> B2["render<br/>~16 µs"]
end
classDef big fill:#dc2626,color:#fff,stroke:#991b1b;
classDef tiny fill:#059669,color:#fff,stroke:#065f46;
classDef small fill:#d97706,color:#fff,stroke:#92400e;
class A1,B1 big; class A2 tiny; class B2 small;Where the difference would matter: rendering thousands of pages in a tight loop with no I/O in the path. Which is exactly what a static site generator does — and is a decent part of why Zola cares about Tera’s performance in a way your CRUD app doesn’t.
# Security: closer than the type-safety story suggests
It’s tempting to assume Maud’s compile-time checking makes it the safer choice. That’s not quite what the difference is.
Both engines escape by default. I verified it rather than trusting the docs — a task titled Write up the <script> escaping gotcha rendered as <script> from both. Tera 2’s default autoescape suffixes, confirmed from the crate source, are .html, .htm and .xml, applied through escape_html.
Neither escapes contextually, and this is the more important fact. A value gets identical treatment whether it lands in HTML text, inside an unquoted attribute, in a URL, or inside a <script> block. OWASP’s guidance is explicit that correct escaping depends on the destination context. So this is unsafe in both:
<!-- unsafe in Maud and unsafe in Tera, equally -->
<script>const user = "{{ username }}";</script>Maud’s type checking does not help here. It verifies your types line up; it has no opinion about where in the document a string is landing. If you’re interpolating into script or style context, you need a JSON encoder or a dedicated escaper, not a template engine.
The genuine difference is in the escape hatches. Maud’s PreEscaped is a Rust type that appears in your source, so grep PreEscaped produces a complete, reviewable list. Tera’s | safe lives in template files, which means your audit has to cover both languages. Marginal, but real when someone asks what your XSS exposure is.
So the honest security summary: Maud’s advantage isn’t stronger escaping, it’s that a class of template bug becomes a build failure. Tera 2 closed part of that gap by validating registered filters and components up front and erroring on undefined access. Neither solves contextual escaping, and neither is going to.
# Ecosystem health, read carefully
Two numbers from crates.io:
| Recent downloads | Total | |
|---|---|---|
| Tera | ~5.68M | ~33.9M |
| Maud | ~1.16M | ~5.42M |
Tera is roughly 5× more used. Part of that is Zola pulling it along, part is Jinja familiarity lowering the barrier.
Release cadence tells a different story than it first appears. Maud’s latest release, 0.27.0, is dated 2 February 2025 — around 17 months ago at time of writing. Tera shipped 2.0.0 on 26 June 2026, a from-scratch rewrite.
It’d be easy to read that as “Maud is dying.” I don’t think that’s right, and I want to be careful not to assert a verdict the data doesn’t support. Look at what 0.27.0 actually contained: axum 0.8 support via axum-core 0.5, Poem 3 support, a parser rewrite, an allocation removed from the axum response path. That’s a small library tracking its ecosystem and finishing its job. Maud does one thing, and the surface area where new features would even go is small.
That said, “quiet” and “unmaintained” look identical from outside until you need a fix. If you’re picking Maud for something long-lived, check recent commit activity yourself rather than taking a release date — or mine — as the whole picture.
And the single most informative signal in this whole comparison: Zola still pins tera = "1.17" on master. Same author as Tera. Three weeks after 2.0 shipped. Nobody is better placed to do that migration, and it hasn’t happened. Whatever you budget for a v1→v2 upgrade, that’s your calibration.
# The decision
Stop thinking about speed. The question that actually decides this is who edits the markup, and what should happen when they get it wrong.
flowchart TB
Q1{"Do non-Rust people<br/>need to edit templates?"}
Q1 -->|Yes| TERA["Tera"]
Q1 -->|No| Q2{"Do you need hot reload<br/>or templates from disk/DB?"}
Q2 -->|Yes| TERA
Q2 -->|No| Q3{"Is the UI component-shaped?<br/>(HTMX fragments, small pieces)"}
Q3 -->|Yes| MAUD["Maud"]
Q3 -->|"No — big content pages"| Q4{"Is copy edited<br/>more often than code?"}
Q4 -->|Yes| TERA
Q4 -->|No| MAUD
classDef m fill:#059669,color:#fff,stroke:#065f46;
classDef t fill:#d97706,color:#fff,stroke:#92400e;
classDef q fill:#2563eb,color:#fff,stroke:#1e40af;
class MAUD m; class TERA t; class Q1,Q2,Q3,Q4 q;Choose Maud when engineers own the markup end to end, the UI is component-shaped, you want one self-contained binary, and you’d rather find template bugs in CI than in production. It pairs particularly well with HTMX, where the server returns small fragments and a fragment is just a function returning Markup.
Choose Tera when someone who doesn’t write Rust needs to edit templates, you want themeable or user-supplied layouts, hot reload materially speeds up your loop, or your team already thinks in Jinja. Zola is the existence proof: its theme ecosystem only works because templates are text.
Pros
- Maud: template bugs are compile errors
- components are plain functions
- methods callable from markup with no view model
- minified output for free
- ~1 µs renders
- tiny runtime
- single sealed binary
Cons
- Maud: only Rust programmers can edit markup
- every copy change is a rebuild and redeploy
- quiet release cadence worth verifying
- ~5x smaller ecosystem
- Tera: serde boundary forces view models and clones
- non-contextual escaping
- ~16 µs renders
- v1→v2 is a substantial migration with macros removed entirely
# The option most teams skip
You can run both in one binary. Maud for application UI and HTMX fragments where engineers own the markup and compile-time checking pays for itself. Tera for content-heavy or themeable pages where somebody else edits and hot reload matters.
That isn’t a compromise or a hedge — it’s the correct answer whenever a single project genuinely has both kinds of surface, which most non-trivial products do. The failure mode is picking one engine for the whole codebase on aesthetic grounds and then fighting it on whichever surface it suits badly.
# Putting it together
Three posts, and the conclusion is almost aggressively boring: choose on your editing model, not on your benchmark.
The 14–17× render gap is real, reproducible, and nearly irrelevant, because ~15 microseconds vanishes inside a database round trip. The things that don’t vanish are structural. Can a designer fix a heading without a Rust build? Does a typo’d field name fail in CI or in front of a user? Those differences are permanent, they compound over a codebase’s life, and no amount of optimization changes either one.
Tera 2.0 made this genuinely more interesting than it was a month ago by pulling failure detection earlier — unregistered filters and components now fail at startup, undefined variables now error instead of silently rendering nothing. The gap narrowed. It didn’t close, because Tera validates registration while Maud hands your markup to rustc.
And if you take one operational thing from all three parts: that pinned tera = "1.17" in Zola’s manifest is worth more than every benchmark I ran. A release being available is not a release being adopted.
Back to part one for the architecture, or part two for the code.
# References
- lambda-fairy/maud — GitHub
- maud CHANGELOG — GitHub
- Tera CHANGELOG — GitHub
- Tera v1 → v2 migration guide — GitHub
- Zola
Cargo.toml— GitHub - crates.io — tera · crates.io — maud
- OWASP XSS Prevention Cheat Sheet — OWASP
- askama-rs/template-benchmark · rosetta-rs/template-benchmarks-rs — benchmark harnesses
- Building a fast website with the MASH stack in Rust — Evan Schwartz
- Are We Web Yet? — Templating — arewewebyet.org
Click to show appreciation
Discussion