Skip to content
gohacks.dev
Web

Templ vs html template in Go and Which Is Better for You

Compare templ and html/template on the same 100 row page: render speed and allocations, error timing, escaping, tooling and when each is the right choice.

9 min read
Summarize in AI
Flat illustration of two options weighed side by side, with the HTML5 logo.

The question comes up in every Go web project: stay with html/template from the standard library, or take on templ and its code generator? The usual answers are slogans (templ is faster, the standard library is simpler), and slogans do not help someone deciding on a real project. This article puts the same page through both and measures what can be measured: the speed, the allocations, when a mistake is caught, and what the output looks like. It then says what I would choose in which situation, and why.

Tested with Go 1.25.6 and templ v0.3.1001, macOS on Apple M1 Max (arm64)

It follows the templ tutorial and the html/template article, uses ideas from template functions, and belongs to the web cluster that ends in a production HTTP server. Everything ran on Go 1.25.6 with templ v0.3.1001, on an Apple M1 Max.

The same page twice

The page is a table of a hundred rows. Each row has a name, a price formatted to two decimals, a "sold out" marker when the stock is zero (which also adds a CSS class to the row) and a "low" marker when it is under three. It has enough control flow to be a fair test, and nothing exotic.

table.templText
package main

import "fmt"

type Item struct {
	Name  string
	Price float64
	Stock int
}

templ Table(items []Item) {
	<table>
		<tbody>
			for _, it := range items {
				<tr class={ templ.KV("sold-out", it.Stock == 0) }>
					<td>{ it.Name }</td>
					<td>{ fmt.Sprintf("%.2f", it.Price) }</td>
					<td>
						if it.Stock == 0 {
							<em>sold out</em>
						} else if it.Stock < 3 {
							<em>low</em>
						}
					</td>
				</tr>
			}
		</tbody>
	</table>
}

The html/template version, plus a second template with a deliberate typo for the error comparison:

classic.goGo
package main

import (
	"html/template"
	"io"
)

var classic = template.Must(template.New("table").Parse(`<table><tbody>
{{range .}}<tr{{if eq .Stock 0}} class="sold-out"{{end}}>
<td>{{.Name}}</td>
<td>{{printf "%.2f" .Price}}</td>
<td>{{if eq .Stock 0}}<em>sold out</em>{{else if lt .Stock 3}}<em>low</em>{{end}}</td>
</tr>
{{end}}</tbody></table>`))

func renderClassic(w io.Writer, items []Item) error { return classic.Execute(w, items) }

// broken has a typo in a field name. It parses fine; it fails when it runs.
var broken = template.Must(template.New("broken").Parse(`<p>{{.Nmae}}</p>`))

Comparing the output and the errors

main.goGo
package main

import (
	"context"
	"fmt"
	"regexp"
	"strings"
)

var ws = regexp.MustCompile(`\s+`)

func main() {
	items := make([]Item, 0, 100)
	for i := 0; i < 100; i++ {
		items = append(items, Item{Name: fmt.Sprintf("Item <%d>", i), Price: float64(i) + 0.5, Stock: i % 5})
	}
	var a, b strings.Builder
	Table(items).Render(context.Background(), &a)
	renderClassic(&b, items)
	na, nb := ws.ReplaceAllString(a.String(), ""), ws.ReplaceAllString(b.String(), "")
	fmt.Println("templ output bytes:", a.Len(), "| html/template output bytes:", b.Len())
	fmt.Println("same markup once whitespace is ignored:", na == nb)
	row := func(s string) string {
		i := strings.Index(s, "<tr")
		j := strings.Index(s[i:], "</tr>")
		return ws.ReplaceAllString(s[i:i+j+5], " ")
	}
	fmt.Println("first row, templ:        ", row(a.String()))
	fmt.Println("first row, html/template:", row(b.String()))
	for i := 0; i < min(len(na), len(nb)); i++ {
		if na[i] != nb[i] {
			fmt.Printf("first difference at byte %d:\n   templ:         ...%s\n   html/template: ...%s\n", i, na[max(0, i-30):i+30], nb[max(0, i-30):i+30])
			break
		}
	}

	var out strings.Builder
	err := broken.Execute(&out, Item{Name: "x"})
	fmt.Println("html/template with a typo, at runtime:\n  ", err)
}

The benchmark renders each page to io.Discard 100 rows at a time:

bench_test.goGo
package main

import (
	"context"
	"fmt"
	"io"
	"testing"
)

func makeItems(n int) []Item {
	items := make([]Item, n)
	for i := range items {
		items[i] = Item{Name: fmt.Sprintf("Item <%d>", i), Price: float64(i) + 0.5, Stock: i % 5}
	}
	return items
}

func BenchmarkTempl(b *testing.B) {
	items, ctx := makeItems(100), context.Background()
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		if err := Table(items).Render(ctx, io.Discard); err != nil {
			b.Fatal(err)
		}
	}
}

func BenchmarkHTMLTemplate(b *testing.B) {
	items := makeItems(100)
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		if err := renderClassic(io.Discard, items); err != nil {
			b.Fatal(err)
		}
	}
}

And a templ file with the same typo as the broken template, compiled to see what the developer sees:

bad.templ.txtText
package main

templ Bad(it Item) {
	<p>{ it.Nmae }</p>
}
Shell
go get github.com/a-h/[email protected] >/dev/null 2>&1
go get -tool github.com/a-h/templ/cmd/[email protected] >/dev/null 2>&1
go tool templ generate >/dev/null 2>&1
echo "--- the two renderings"
go run .
echo
echo "--- the same typo in a templ file"
cp bad.templ.txt bad.templ
go tool templ generate >/dev/null 2>&1
go build -o /dev/null . 2>&1 | head -4 || true
rm -f bad.templ bad_templ.go
go tool templ generate >/dev/null 2>&1
echo
echo "--- benchmark, 100 rows"
go test -run '^$' -bench . -benchmem -count=1 . | grep -E "Benchmark|cpu:"
Text
--- the two renderings
templ output bytes: 7490 | html/template output bytes: 7271
same markup once whitespace is ignored: false
first row, templ:         <tr class="sold-out"><td>Item &lt;0&gt;</td><td>0.50</td><td><em>sold out</em></td></tr>
first row, html/template: <tr class="sold-out"> <td>Item &lt;0&gt;</td> <td>0.50</td> <td><em>sold out</em></td> </tr>
first difference at byte 102:
   templ:         ...><em>soldout</em></td></tr><trclass=""><td>Item&lt;1&gt;</td
   html/template: ...><em>soldout</em></td></tr><tr><td>Item&lt;1&gt;</td><td>1.5
html/template with a typo, at runtime:
   template: broken:1:5: executing "broken" at <.Nmae>: can't evaluate field Nmae in type main.Item

--- the same typo in a templ file
# example.test/lab
./bad_templ.go:37:69: it.Nmae undefined (type Item has no field or method Nmae)

--- benchmark, 100 rows
cpu: Apple M1 Max
BenchmarkTempl-10           	   22634	     52574 ns/op	   35872 B/op	    1042 allocs/op
BenchmarkHTMLTemplate-10    	    3627	    328707 ns/op	  110719 B/op	    4394 allocs/op

What the numbers say

Speed. templ rendered the table in about 52.7 microseconds with 1,042 allocations and 36 KB; html/template took 327.8 microseconds with 4,394 allocations and 111 KB. That is a factor of six in time and four in allocations. The reason is structural: templ's generated code writes constant strings and calls an escape function for each dynamic value, with no reflection, while html/template evaluates a parsed tree, looks fields up by name through reflect, and runs context-aware escaping on every action.

Whether it matters depends on the arithmetic of your service. The gap is about 275 microseconds per page. At 50 page renders per second that is roughly 1.4 percent of one core. At 1,000 per second it is more than a quarter of a core. The database query that feeds the page usually costs more than all of the rendering, so for most applications the speed difference is real and unimportant. It starts to count for a high-traffic page, a long list, or a service where rendering is the main work, such as an HTML-producing API or a static site generator.

When mistakes are caught. The misspelled field Nmae shows the larger difference. html/template parsed the template without complaint (template.Must only checks syntax) and failed on execution: can't evaluate field Nmae in type main.Item. That error arrives when someone requests the page, possibly in production, possibly on a branch no test exercises. templ rejected the same typo at build time: it.Nmae undefined (type Item has no field or method Nmae). One caveat from the output: the error location is bad_templ.go:37:69, a position in the generated Go file, not in the .templ source. Editors with the language server show it at the right place in your template, but a bare go build in CI prints the generated path, and you have to open the generated file to find the line. That is a small papercut, and a real one for newcomers.

Escaping. Both escape by context, and both escaped <0> in the item name to &lt;0&gt;. Neither lets you forget; the difference is in the escape hatches. In html/template it is the template.HTML type, which a function can return from anywhere. In templ it is templ.Raw and templ.SafeURL, which are explicit calls in the template. Both deserve a search in code review.

Output. The two pages are not byte-identical, and the difference is instructive. templ's output is compact, with the whitespace between tags removed, and it emitted <tr class=""> for rows where templ.KV("sold-out", false) produced no class. The html/template version printed no attribute for those rows, because its if wrapped the whole attribute. The empty attribute is harmless in a browser, but it shows that the two engines are different programs and a migration needs a visual or golden-file check, as in testing templ components. If you want the attribute omitted in templ, use a conditional attribute (if cond { class="sold-out" }) rather than templ.KV on its own.

What the numbers do not say

They do not measure developer time. templ gives you completion, go to definition, rename and typed parameters, and that is where its value shows after the first month: refactoring a struct and having the compiler list every template that used the old field. html/template gives you none of that, and it also gives you something templ cannot: templates are data. You can load them from disk at start-up or on every request in development, ship them next to the binary, let a theme author in a different team edit a file without a Go toolchain, and change them without a build. If your product has user-editable themes or templates stored in a database, html/template is the only one of the two that works at all.

They do not measure build complexity either. templ adds a generation step to every build, every CI job and every Dockerfile; forgetting it produces stale output or a compile error. html/template adds nothing. For a team with an established build pipeline this is a ten-minute change; for a script, a small internal tool or a library, it is a real cost.

And the learning curve differs in a way that benchmarks hide. html/template syntax is small but unusual ({{if eq .Stock 0}}, pipelines, with, block, the dot that changes meaning inside range), and many people never become comfortable with it. templ syntax is HTML with Go in braces, and a Go developer reads it on the first day. Designers who know HTML but not Go can read both, and edit neither without help.

What I would choose

  • A new application with a Go team that renders a lot of HTML, especially with HTMX: templ. The typed components, the fragments and the editor support pay back inside a few weeks.
  • A small site, an internal tool, or a service with a handful of pages: html/template. Nothing to generate, nothing to install, and the performance is irrelevant at that size.
  • Templates that non-developers edit, or that are stored outside the binary: html/template, or a different template language altogether.
  • A large existing html/template code base: leave it, and write new pages in templ using the bridge functions, as described in the tutorial. Rewrites for speed alone are rarely worth it.
  • Where rendering is the hot path (long tables, report generation, a static site generator): templ, and measure with your own data.

Whichever you pick, put the rendering behind a function that takes typed data and returns bytes, so the choice stays reversible. The standard library reference for html/template is on pkg.go.dev, and the templ guide is at templ.guide.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.