Performance
gotmpl4j is an interpreter for Go’s text/template syntax, yet it renders in the same
throughput tier as the mainstream compiled JVM template engines — and faster than native Go
text/template on the same templates. This page summarises the JMH benchmark suite shipped in
the gotmpl4j-benchmarks module and how to reproduce it.
|
Numbers below are indicative — measured with JMH (2 forks × 100 measurement iterations, so the error bands are ±1–2 %) on an otherwise-idle developer workstation (JDK 17). A single short run is noise-dominated and not quotable; always use multiple forks and ~100 iterations, and re-run on your own box. Throughput is hardware- and JVM-specific — the point is the relative standing of the engines, not the absolute figures. |
1. What’s compared
The suite pits gotmpl4j against four of the most-used JVM template engines, plus Go itself as a cross-runtime reference:
| Engine | Role |
|---|---|
gotmpl4j |
This library — Go |
FreeMarker |
Mature, widely used Spring Boot view engine. |
Thymeleaf |
The default Spring Boot HTML view engine. |
Mustache |
|
Pebble |
Twig/Jinja-style engine with a compiled AST. |
Go |
The original, run natively via |
The workload mirrors the canonical
mbosecke/template-benchmark "stocks" table:
render a list of N stock rows, each with a loop, a gain/loss if/else conditional, six field
accesses, and two floating-point values (price, percentage ratio). gotmpl4j and Go share the
same table.gotmpl, so that column is a true like-for-like across runtimes.
2. Results
Render throughput in ops/µs — higher is better. Templates are pre-parsed in @Setup, so
this measures the hot render path, not compile cost.
| Workload | gotmpl4j | FreeMarker | Thymeleaf | Mustache | Pebble | Go (ref) |
|---|---|---|---|---|---|---|
Interpolation ( |
3.20 |
1.84 |
0.19 |
4.25 |
2.69 |
1.08 |
Table, 10 rows |
0.061 |
0.053 |
0.012 |
0.062 |
0.056 |
0.021 |
Table, 100 rows |
0.0060 |
0.0058 |
0.0013 |
0.0064 |
0.0060 |
0.0021 |
Table, 1000 rows |
0.00060 |
0.00053 |
0.00013 |
0.00070 |
0.00060 |
0.00021 |
2.1. Reading the numbers
-
gotmpl4j is in the top tier on the table. On the loop-and-conditional stocks workload it is neck-and-neck with Pebble, ahead of FreeMarker, far ahead of Thymeleaf, and a hair behind only logic-less Mustache — a strong result for a full Go-template + Sprig interpreter. The float-formatting and render-allocation optimizations (see Optimization history) land squarely on this workload, which formats two doubles per row.
-
On interpolation gotmpl4j is second only to Mustache (the thinnest possible render path), well ahead of Pebble, FreeMarker, and far ahead of Thymeleaf.
-
gotmpl4j out-throughputs native Go
text/templateon the JVM, rendering the same template file (the JIT edges the Go runtime here — see the fairness note). -
The headline: gotmpl4j is the only engine here that speaks Go
text/template+ Sprig, and it pays no throughput penalty for that compatibility.
|
Fairness note
All five JVM engines materialise their output to a |
3. gotmpl4j feature workloads
The other JVM engines can’t express gotmpl4j’s distinctive surface — Sprig functions, pipelines,
control flow, printf, template composition. But its reference implementation can: Go
text/template + Masterminds/sprig is exactly what gotmpl4j re-implements, so the
FeatureBenchmark suite renders the same templates and data through both and reports
gotmpl4j against native Go+Sprig — the most direct apples-to-apples there is.
| Workload | gotmpl4j | Go+Sprig (ref) | gotmpl4j is | What it exercises |
|---|---|---|---|---|
Sprig pipeline |
0.24 |
0.015 |
16× faster |
|
Control flow |
0.16 |
0.012 |
14× faster |
nested |
List / dict |
0.17 |
0.019 |
9× faster |
|
Composition |
0.13 |
0.020 |
6.7× faster |
|
Large output |
0.027 |
0.012 |
2.2× faster |
200-element loop, a paragraph each (writer-stress) |
|
0.013 |
0.011 |
1.2× faster |
|
gotmpl4j out-throughputs native Go+Sprig on every feature workload — 6–16× across the Sprig
and control-flow surface (the JVM JIT versus the Go interpreter, the same dynamic as the table),
and now ahead on printf too. printf used to be the one place Go won (~2×); pre-compiling
its rewrite patterns (a per-call Pattern.compile was the cost) cut that allocation by
two-thirds and lifted throughput +57 %, flipping it from a ~2× loss to a ~1.2× win. (As with
the table, the Go column writes to io.Discard while gotmpl4j materialises a String, so Go
does strictly less work — yet still loses on all six.)
4. Parse / compile cost
Render is the hot path (it runs per request), but a template is also parsed once — helm
template-style one-shot workloads pay it on every invocation. ParseBenchmark measures that
cold parse across the same engines, using the identical table template each renders. Each
engine is made to re-parse on every call (Mustache via a fresh DefaultMustacheFactory, Pebble
via a cacheActive(false) engine), so the number is a real parse, not a cached-template lookup.
Thymeleaf is omitted — its API doesn’t separate parse from render.
| Engine | µs/op | Alloc (B/op) | Grammar parsed |
|---|---|---|---|
Mustache |
16.8 |
9,936 |
logic-less tags only |
Pebble |
69.7 |
76,955 |
expressions + filters |
gotmpl4j |
123.0 |
81,956 |
full Go actions — pipelines, |
FreeMarker |
384.5 |
68,187 |
full directive language |
The ordering tracks grammar richness, not engine quality: Mustache parses fastest because it
has almost nothing to parse (logic-less tags), while gotmpl4j parses Go’s entire action grammar.
gotmpl4j parses ~3× faster than FreeMarker (the other full-feature engine) and slower than the
leaner Mustache/Pebble. Parse is a one-time cost amortised across every render — the render
tables above are the fair head-to-head. gotmpl4j is also the heaviest table-parser on allocation
(a Token object plus a substring value per token, inherent to the token-list model); the
#96/#97 work trimmed the secondary allocation, and the
remaining bulk would need an offset-based token redesign.
A second benchmark, gotmpl4jParseLargeChart, parses a Helm-chart-shaped template (multi-KB of
YAML, hundreds of actions) at 1,169 µs/op and 1.25 MB/op — the workload where the #96 lexer
fix turned an O(n²) scan into a ~5× speedup. See Optimization history → Parse path below.
5. Where gotmpl4j sits in the wider field
The engines above are all interpreters (they walk an AST at render time). A separate tier of compiled engines — Rocker, JTE, JStachio, and others — generate Java bytecode from templates and therefore sit a notch above every interpreter measured here, gotmpl4j included. For that broader landscape see the canonical mbosecke/template-benchmark and the agentgt fork (which adds JTE and JStachio).
gotmpl4j’s niche is Go-template compatibility, not beating a code generator: if you need to
render Helm-/Hugo-style text/template + Sprig templates on the JVM, gotmpl4j does it at
mainstream-interpreter speed.
6. Reproducing
The benchmarks live in the (unpublished) gotmpl4j-benchmarks module:
# Build the shaded JMH runner
./mvnw -q -pl gotmpl4j-benchmarks -am package
# Run everything — multiple forks + ~100 iterations for a quotable number
# (a single short run is noise-dominated; quote the JMH error band, not the bare score)
java -jar gotmpl4j-benchmarks/target/benchmarks.jar -f 2 -wi 5 -i 100
# Just the cross-engine comparison, or just gotmpl4j's feature suite
java -jar gotmpl4j-benchmarks/target/benchmarks.jar "InterpolationBenchmark|TableBenchmark" -f 2 -wi 5 -i 100
java -jar gotmpl4j-benchmarks/target/benchmarks.jar FeatureBenchmark -f 2 -wi 5 -i 100
# Parse/compile cost (incl. the Helm-chart-shaped large template)
java -jar gotmpl4j-benchmarks/target/benchmarks.jar ParseBenchmark -f 2 -wi 5 -i 100 -prof gc
# One class, with allocation profiling
java -jar gotmpl4j-benchmarks/target/benchmarks.jar TableBenchmark -prof gc
# The Go reference (reuses the identical .gotmpl files)
cd benchmarks/go && go test -bench . -benchmem
7. Optimization history
The interpreter has been profiled with jvmlens (JFR →
LLM-ready hot-path/allocation summary) and tuned along the way. The floatString row below is
written up step-by-step — profile → fix → prove byte-identical → guard — as the
jvmlens floatString case
study, and the inline JMH keep=/baseline= A/B loop used here is documented on the
jvmlens usage page.
| Change | Workload | Throughput | Allocation (B/op) |
|---|---|---|---|
Shared reflective accessor cache |
Interpolation |
1.93 → 2.96 ops/µs |
744 → 480 (−35%) |
Table 100 |
0.005 → 0.008 ops/µs |
203,513 → 137,753 (−32%) |
|
|
Table 100 |
— |
137,753 → 106,761 (−23%) |
Table 1000 |
— |
1,394,849 → 1,038,586 (−26%) |
|
|
Table 1000 |
— |
1,038,586 → 771,716 (−26%) |
Unsync writer + per-thread float-format scratch |
Table 1000 |
— |
771,716 → 644,082 (−17%) |
Drop per-call arg |
Sprig pipeline |
— |
3,488 → 2,912 (−16%) |
Pre-compile |
printf |
0.007 → 0.011 ops/µs |
123,136 → 40,288 (−67%) |
Cumulatively, per-render allocation on the table workload dropped by roughly two-thirds versus
the original baseline (n=1000: 2.05 MB → 0.64 MB, −69%). The wins came from caching the
per-class property accessor map (amortising JavaBeans introspection across renders), rewriting
GoFmt.floatString to format directly from Double.toString digits into a presized builder
with no intermediate substrings, reusing per-thread scratch buffers for that formatting, and
replacing the synchronized StringWriter output sink with an unsynchronized StringBuilder.
Each was found and verified with jvmlens plus a multi-fork JMH gc.alloc.rate.norm A/B.
7.1. Parse path
The lexer/parser is exercised separately by ParseBenchmark, including a Helm-chart-shaped
template (multi-KB of YAML with hundreds of {{ … }} actions) — the shape where parse cost,
not render cost, dominates a helm template run.
| Change | Workload | Result |
|---|---|---|
Lexer "starts-at" checks via |
Parse large chart |
6.9 ms → 1.3 ms (≈5× faster) |
|
Parse large chart |
1,267,634 → 1,248,089 B/op (−1.5%) |
The lexer win was a genuine O(n²): each {{ re-scanned the entire remaining template to test
whether a delimiter/comment started at the cursor, so cost grew with template length × action
count. Replacing the four input.indexOf(delim, pos) == pos checks with startsWith(delim, pos)
makes each check local — parse output is byte-identical (conformance green) and allocation is
unchanged; it is pure CPU. The follow-up ArrayList node lists trim a small, deterministic slice
of parse allocation; the dominant remaining cost (a Token object plus a substring value per
token) is inherent to the token-list model and would need an offset-based token redesign to move.