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 text/template + Sprig, pure Java.

FreeMarker

Mature, widely used Spring Boot view engine.

Thymeleaf

The default Spring Boot HTML view engine.

Mustache

mustache.java — logic-less, the thinnest possible render path.

Pebble

Twig/Jinja-style engine with a compiled AST.

Go text/template

The original, run natively via go test -bench — a runtime reference.

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 (Hello <name>)

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/template on 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 String/StringWriter, so those columns are apples-to-apples. The Go benchmark writes to io.Discard (no output buffer), so it does strictly less work and allocates far less — treat the Go column as a runtime reference, not a like-for-like number.

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

{{ . | upper | trunc 5 | trimSuffix "X" | repeat 2 | quote }}

Control flow

0.16

0.012

14× faster

nested if/else if/else, with, range … else

List / dict

0.17

0.019

9× faster

dict, keys, sortAlpha, index, join, len

Composition

0.13

0.020

6.7× faster

define + template invocation per row

Large output

0.027

0.012

2.2× faster

200-element loop, a paragraph each (writer-stress)

printf

0.013

0.011

1.2× faster

printf "%s=%d (%.2f%%)" per row

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, if/range/with, {{- -}} trim, define/template

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%)

GoFmt.floatString without BigDecimal

Table 100

137,753 → 106,761 (−23%)

Table 1000

1,394,849 → 1,038,586 (−26%)

floatString without intermediate substrings

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 subList wrapper (ArrayList CommandNode)

Sprig pipeline

3,488 → 2,912 (−16%)

Pre-compile printf regex patterns (static Pattern)

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 startsWith, not an indexOf rest-of-template scan

Parse large chart

6.9 ms → 1.3 ms (≈5× faster)

ArrayList PipeNode/ChainNode + presized token list

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.