Text and HTML Templates
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Go 1.27 (go1.27.2), checked on 2026-10-18; every go.mod shown has the line go 1.27.
Go ships two template packages in its standard library. text/template turns a template and some data into any text
format (configuration files, source code, e-mails, command output), and html/template has the identical API but
understands HTML, CSS, JavaScript and URLs and escapes every value for the place where it lands, which is what keeps a
web page free of cross-site scripting (XSS) holes. This page covers the template language once, since both packages
share it, then the escaping model that only html/template adds, then the practical parts: nested templates and
layouts, error messages, rendering the shelf catalogue as HTML from cmd/shelfd, testing templates, and the Go
templates that you meet outside Go programs, in Helm charts and in kubectl -o go-template.
It builds on Structs and Embedding and
Methods and Receivers (templates read exported fields and call
exported methods), Interfaces and
Error Handling (execution errors wrap their cause). Related pages:
fmt, Strings and Text Processing (printf in a
template is fmt.Sprintf), I/O, Files and the Filesystem
(io.Writer, io/fs and embed.FS, which templates are loaded from),
Directives, Code Generation and Embedding
(//go:embed and go generate),
net/http Clients and Servers (the handler that serves
the page), encoding/json (how html/template embeds data in
JavaScript), Testing and
Iterators and Range-over-Func (templates can range over
iterators). Outside Go, the Go template language is the template language of
Helm and the -o go-template output format of
kubectl; both are revisited at the end. For comparison with other
server-side template engines see Axum server-side templates,
Flask Jinja templates, Django templates and
Vapor Leaf templates.
Neither of the two reference books used for this section covers templates; the page is built from the package
documentation and the release notes. Every example was compiled, vetted with go vet and run with Go 1.27.2; the output
shown is the real output (compiler, parser and escaper messages are copied from real runs; trailing white space at line
ends is trimmed for readability). The go.mod of the example module is:
module example.com/shelf
go 1.27
Two packages, one language
text/template |
html/template |
|
|---|---|---|
Import path |
|
|
Output |
Anything; copied verbatim |
HTML fragments, safe against code injection |
Escaping |
None; values are printed as |
Contextual: HTML text, attribute, URL, JavaScript and CSS each get their own escaper |
API |
|
Same names and behaviour, plus the content types ( |
Trust model |
The template author is trusted; the data may be anything |
The template author is trusted; the data is treated as untrusted |
Use it for |
Configuration, e-mail bodies, source code, CLI output, Helm-style manifests |
Every template whose output a browser (or e-mail client) will render as HTML |
html/template wraps text/template, so everything in the next sections about actions, pipelines, variables,
functions, associated templates and options applies to both. The packages differ in one rule that matters in practice:
use html/template whenever the result is HTML, even for pages that "only" show your own data. Titles, names and
comments eventually contain <, & and quotes, and an unescaped one is a bug at best and a vulnerability at worst.
Both import paths are called template, so a program that needs both aliases one of them, as the comparison example
below does.
Source: text/template and html/template
The life of a template
A template goes through three steps. template.New(name) creates an empty, named template. Parse(text) turns the
source text into a parse tree and reports syntax errors. Execute(w, data) walks the tree with data as the starting
value, writes the result to the io.Writer w and reports execution errors. Parsing is the expensive part and a parsed
template is immutable in practice, so a program parses its templates once, at start-up, and executes them many times,
from as many goroutines as it likes.
template.Must takes a (*Template, error) pair and panics on an error. It is meant for package-level variables and
main, where a template that does not parse is a programming error that should stop the program at start-up, not a
runtime condition to handle.
package main
import (
"fmt"
"os"
"text/template"
)
// Member is a library member.
type Member struct {
Name string
Loans []string
}
// Greeting returns a salutation; templates can call niladic methods.
func (m Member) Greeting() string { return "Welcome back, " + m.Name }
// Remaining reports how many loans are still allowed; the second result
// is an error that stops execution when non-nil.
func (m Member) Remaining(limit int) (int, error) {
if len(m.Loans) > limit {
return 0, fmt.Errorf("member %s is over the limit of %d", m.Name, limit)
}
return limit - len(m.Loans), nil
}
func main() {
const text = "{{.Greeting}}!\n" +
"You have {{len .Loans}} loan(s): {{range .Loans}}[{{.}}]{{end}}\n" +
"You may borrow {{.Remaining 4}} more.\n"
tmpl, err := template.New("welcome").Parse(text)
if err != nil {
fmt.Fprintln(os.Stderr, "parse:", err)
os.Exit(1)
}
m := Member{Name: "Ada", Loans: []string{"Go", "Rust"}}
if err := tmpl.Execute(os.Stdout, m); err != nil {
fmt.Fprintln(os.Stderr, "execute:", err)
os.Exit(1)
}
// A method that returns a non-nil error stops execution.
over := Member{Name: "Bob", Loans: []string{"a", "b", "c", "d", "e"}}
err = tmpl.Execute(os.Stdout, over)
fmt.Println()
fmt.Println("error:", err)
}
Welcome back, Ada!
You have 2 loan(s): [Go][Rust]
You may borrow 2 more.
Welcome back, Bob!
You have 5 loan(s): [a][b][c][d][e]
You may borrow
error: template: welcome:3:17: executing "welcome" at <.Remaining>: error calling Remaining: member Bob is over the limit of 4
Source: text/template: Template.Execute and text/template: Must
Points worth noticing in the output:
-
The template text is ordinary data. Everything outside
{{ }}is copied unchanged; everything inside is an action. -
{{.Greeting}}called the methodGreetingof the valuem. A template can call a method with no arguments by naming it, or with arguments by listing them after the name, as in{{.Remaining 4}}. -
A method may return one value, or two values whose second is an
error. When the error is non-nil, execution stops andExecutereturns the error. Output produced before the failure has already been written: for Bob the writer receivedYou may borrow ` and then nothing. The error message names the template, the line and column and the action that failed (`welcome:3:17), and wraps the cause. -
Executereturns an error for a failingio.Writeras well, and then the error is the writer’s error itself.
Partial output matters for web handlers; see The shelf catalogue as HTML for the usual remedy of rendering into a buffer.
Data and dot
The cursor of a template is written . and called dot. It starts as the data argument of Execute and changes
inside range, with and template actions. $ always refers to the data that Execute received, whatever dot is
at that point.
| Expression | Meaning |
|---|---|
|
The current value of dot |
|
Field |
|
A field that is a slice, map or any other value |
|
Key |
|
Chained fields and keys, to any depth; pointers are dereferenced automatically |
|
A method with no arguments (called) |
|
A method with arguments (the call can be the last element of a chain) |
|
Field of the root data, even inside a |
|
The |
Anything that is not exported cannot be reached. The template package uses reflection, so only exported fields and
methods are visible, and a lower-case name gives a clear execution error (see
Execution errors). A pointer in the path is followed automatically; a nil pointer in the middle
of a chain is an error, not an empty string. A value that is a function is not called when it is a field or a map
entry; it is called with the call function ({{call .Hook 1 2}}), and it can be tested with if.
Actions
An action is anything between the delimiters. Parse accepts these forms:
| Action | Effect |
|---|---|
|
A comment; discarded. It must start and end at the delimiters and does not nest |
|
Prints the value of the pipeline, as |
|
Runs T1 when the value of the pipeline is not empty |
|
With a fallback branch |
|
A chain of conditions, equivalent to nested |
|
Runs T1 once for each element, with dot set to the element |
|
Runs T0 when there are no elements; dot is unchanged inside T0 |
|
Leave the innermost |
|
Runs T1 with dot set to the value when it is not empty |
|
A chain of |
|
Defines an associated template; only at the top level of a template text |
|
Runs the named template with nil data, or with dot set to the pipeline |
|
Defines the template and runs it in place; used for overridable defaults |
range accepts an array, slice, map, channel, integer (Go 1.24), or an iter.Seq or iter.Seq2 function (Go 1.24).
A map is visited in sorted key order when its keys have a defined order (strings and numbers), which makes template
output deterministic, unlike a for range loop over a map in Go code. With two variables, {{range $i, $v := .Books}}
sets $i to the index (or the key) and $v to the element; with one variable, {{range $v := .Books}}, the variable
is the element, the opposite of for i := range in Go code, where a single variable is the index.
package main
import (
"fmt"
"iter"
"os"
"text/template"
"example.com/shelf/catalog"
)
type Shelf struct {
Name string
Books []catalog.Book
Owner *catalog.Author
Notes map[string]string
Overdue []catalog.Loan
}
func (s Shelf) Titles() iter.Seq[string] {
return func(yield func(string) bool) {
for _, b := range s.Books {
if !yield(b.Title) {
return
}
}
}
}
func main() {
const text = `{{- /* A comment; the dashes trim surrounding white space. */ -}}
Shelf {{.Name | printf "%q"}}
{{- if not .Books}}
(empty)
{{- else if eq (len .Books) 1}}
one book
{{- else}}
{{len .Books}} books
{{- end}}
{{range $i, $b := .Books -}}
{{$i}}: {{$b.Title}}{{with $b.Authors}} by {{(index . 0).Name}}{{else}} (anonymous){{end}}
{{end -}}
{{with .Owner}}Owner: {{.Name}}{{else with .Notes}}Notes: {{len .}}{{else}}No owner, no notes{{end}}
{{range $k, $v := .Notes}}{{$k}}={{$v}}; {{end}}
{{range .Overdue}}{{.Book}}{{else}}Nothing is overdue.{{end}}
{{range 3}}{{.}} {{end}}
{{range .Titles}}{{if eq . "Skip me"}}{{continue}}{{end}}{{if eq . "Stop"}}{{break}}{{end}}<{{.}}>{{end}}
`
tmpl := template.Must(template.New("shelf").Parse(text))
s := Shelf{
Name: "Go",
Books: []catalog.Book{
{Title: "First", Authors: []catalog.Author{{Name: "Ada"}}},
{Title: "Skip me"},
{Title: "Third"},
{Title: "Stop"},
{Title: "Never shown"},
},
Notes: map[string]string{"room": "2", "floor": "1"},
}
if err := tmpl.Execute(os.Stdout, s); err != nil {
fmt.Fprintln(os.Stderr, err)
}
}
Shelf "Go"
5 books
0: First by Ada
1: Skip me (anonymous)
2: Third (anonymous)
3: Stop (anonymous)
4: Never shown (anonymous)
Notes: 2
floor=1; room=2;
Nothing is overdue.
0 1 2
<First><Third>
Source: text/template: Actions
Details that the example shows:
-
The first line,
{{- /* ... */ -}}, is a comment with trim markers; the comment produces no output and the markers remove the white space around it, so the output begins withShelf. -
{{.Name | printf "%q"}}is a pipeline (see below) that quotes the name. -
{{- else if eq (len .Books) 1}}shows that anifchain is not "one big condition": thenot .Bookstest is false, andeq (len .Books) 1is false too, so the plainelseran. The branch printed5 books. -
{{with $b.Authors}} by {{(index . 0).Name}}{{else}} (anonymous){{end}}: insidewith, dot is the author slice; an empty slice, andnil, take theelsebranch. Parentheses groupindex . 0so that.Nameapplies to its result. -
{{with .Owner}}...{{else with .Notes}}...{{else}}...is the Go 1.23 chain: the owner is anilpointer, so the secondwithran with dot set to the notes map (len .is 2). Before Go 1.23 the same logic needed a nested{{else}}{{with ...}}...{{end}}{{end}}. -
{{range .Overdue}}...{{else}}Nothing is overdue.{{end}}uses therangeelsefor the empty case. -
{{range 3}}iterates over0 1 2, and{{range .Titles}}ranges over a method that returns aniter.Seq[string], wherecontinueskippedSkip meandbreakended the loop atStop, soNever shownis never visited. Both forms need Go 1.24 or later. -
The notes were printed in sorted key order (
floorbeforeroom), although the Go map literal listsroomfirst.
White space and trim markers
Text between actions is copied exactly, including the newlines that make a template readable in a source file. If the
left delimiter is followed by a minus and a white-space character, {{- x}}, all white space before the action is
removed; if the right delimiter is preceded by a space and a minus, {{x -}}, all white space after it is removed.
The white space is required: {{-3}} is the number minus three, while {{- 3}} is 3 with trimmed predecessor.
White space is space, tab, carriage return and newline. Trimming applies to the adjacent text, not to the output of
other actions. Most templates that generate line-oriented output (YAML, source code, e-mail) use trim markers on every
control action so that if, range and end lines leave no blank lines behind.
Which values count as true
if, with, and, or and not test whether a value is "empty". The rule is that of template.IsTrue: false, zero
numbers, nil pointers and interfaces, and arrays, slices, maps and strings of length zero are false; everything else
is true.
package main
import (
"fmt"
"text/template"
"time"
)
type Loan struct{ Member string }
func main() {
var nilLoan *Loan
var nilErr error
values := []struct {
label string
v any
}{
{"false", false},
{"0", 0},
{"0.0", 0.0},
{`""`, ""},
{"nil", nil},
{"nil *Loan", nilLoan},
{"nil error", nilErr},
{"[]int{}", []int{}},
{"map[string]int{}", map[string]int{}},
{"&Loan{}", &Loan{}},
{"Loan{} (empty struct)", Loan{}},
{"time.Time{} (zero)", time.Time{}},
{`"false"`, "false"},
{"[]int{0}", []int{0}},
}
for _, c := range values {
truth, ok := template.IsTrue(c.v)
fmt.Printf("%-24s truth=%-5t ok=%t\n", c.label, truth, ok)
}
}
false truth=false ok=true
0 truth=false ok=true
0.0 truth=false ok=true
"" truth=false ok=true
nil truth=false ok=true
nil *Loan truth=false ok=true
nil error truth=false ok=true
[]int{} truth=false ok=true
map[string]int{} truth=false ok=true
&Loan{} truth=true ok=true
Loan{} (empty struct) truth=true ok=true
time.Time{} (zero) truth=true ok=true
"false" truth=true ok=true
[]int{0} truth=true ok=true
Source: text/template: IsTrue
The surprises are the last rows. A struct is always true, even when it is the zero value, because there is no general
notion of "an empty struct": time.Time{} is true. Test the property you mean, for example {{if not .Due.IsZero}} for "has a due date", or add a method to the type. The string "false" is true, and so is a slice
that holds a single zero value.
Variables
A pipeline can be stored in a variable whose name starts with $. {{$n := len .Tags}} declares $n and prints
nothing; {{$n = 10}} assigns to an existing variable. A variable’s scope extends to the end of the control action
(if, with, range) in which it was declared, or to the end of the template when it was declared at the top level.
range can declare one or two variables, as shown above. A {{template}} call does not inherit the caller’s variables,
and referring to an undeclared one is a parse error (undefined variable "$x"), reported before anything runs. Assigning
inside a nested block with = changes the outer variable, whereas := would declare a new one that disappears at end;
this is the same shadowing rule as in Go code.
Pipelines, arguments and functions
An argument is a constant, nil, dot, a variable, a field, a map key, a method or a function name, or a parenthesised
argument. Constants follow Go’s syntax: {{1_000}}, {{0x1F}}, {{'a'}} (the rune, printed as 97), {{1.5e3}},
{{true}}, interpreted strings with escapes and raw strings in backquotes.
A pipeline is one or more commands separated by |. The result of each command is passed as the last argument of the
next, so {{"ada lovelace" | upper | printf "%s!"}} is printf("%s!", upper("ada lovelace")). A command that returns an
error as a second value stops execution when it is non-nil.
package main
import (
"errors"
"fmt"
"os"
"strings"
"text/template"
)
func main() {
funcs := template.FuncMap{
"upper": strings.ToUpper,
"join": strings.Join,
"initials": func(name string) string {
var b strings.Builder
for _, w := range strings.Fields(name) {
b.WriteString(w[:1])
}
return b.String()
},
"fail": func() (string, error) { return "", errors.New("lookup failed") },
}
const text = `printf: {{printf "%-8s|%5.1f|%03d" "shelf" 3.14159 7}}
pipeline: {{"ada lovelace" | upper | printf "%s!"}}
paren: {{printf "%q" (join .Tags ", ")}}
initials: {{initials .Author}}
index: {{index .Tags 1}} {{index .Stock "go"}} {{index .Stock "rust"}}
slice: {{slice .Author 0 3}} {{slice .Tags 1}}
len: {{len .Tags}} {{len .Stock}}
eq: {{eq .Count 3}} {{eq .Author "x" "Ada Lovelace"}} {{ne .Count 3}}
lt: {{lt 1 2}} {{le 2 2}} {{gt 1.5 1.2}} {{ge "b" "a"}}
and/or: {{and .Count .Author}} | {{or 0 "" "fallback"}} | {{not .Count}}
print: {{print "a" 1 2 "b"}} {{println "end"}}
var: {{$n := len .Tags}}{{$n}} {{$n = 10}}{{$n}}
root: {{range .Tags}}{{$.Author}}/{{.}} {{end}}
html: {{html "<b>&</b>"}} {{js "it's"}} {{urlquery "a b&c"}}
const: {{1_000}} {{0x1F}} {{'a'}} {{1.5e3}} {{true}} {{"tab\t|"}}
`
tmpl := template.Must(template.New("t").Funcs(funcs).Parse(text))
data := map[string]any{
"Tags": []string{"go", "templates", "html"},
"Stock": map[string]int{"go": 4},
"Author": "Ada Lovelace",
"Count": 3,
}
if err := tmpl.Execute(os.Stdout, data); err != nil {
fmt.Fprintln(os.Stderr, "execute:", err)
}
// A function with an error result stops execution.
bad := template.Must(template.New("bad").Funcs(funcs).Parse("before {{fail}} after\n"))
err := bad.Execute(os.Stdout, nil)
fmt.Println()
fmt.Println("error:", err)
}
printf: shelf | 3.1|007
pipeline: ADA LOVELACE!
paren: "go, templates, html"
initials: AL
index: templates 4 0
slice: Ada [templates html]
len: 3 1
eq: true true false
lt: true true true true
and/or: Ada Lovelace | fallback | false
print: a1 2b end
var: 3 10
root: Ada Lovelace/go Ada Lovelace/templates Ada Lovelace/html
html: <b>&</b> it\'s a+b%26c
const: 1000 31 97 1500 true tab |
before
error: template: bad:1:9: executing "bad" at <fail>: error calling fail: lookup failed
Source: text/template: Pipelines and text/template: Functions
The predefined functions
| Function | Description |
|---|---|
|
Boolean logic over truthiness. |
|
Comparisons. |
|
Length of a string, array, slice, map or channel |
|
|
|
|
|
|
|
Calls a function-valued field or map entry: |
|
Escape the text of their arguments for HTML, JavaScript and URL queries. They exist in |
Functions are looked up first in the template’s own FuncMap, then in the predefined set, so a custom function replaces
a predefined one of the same name; avoid doing that, because the next reader expects len to be len.
Custom functions
Template.Funcs(template.FuncMap{...}) adds functions. A function must have one result, or two with the second being
an error, and it can take arguments of any type that the pipeline can supply (a parameter of type any or
reflect.Value accepts every type). The map must be installed before Parse, because the parser resolves function names while
reading the template; a template that mentions an unknown function does not parse. Funcs panics when a map value is not
a suitable function or the name cannot be used as a function name, so call it during start-up.
Keep template functions small and pure: formatting (dates, money, ISBN hyphenation), simple arithmetic that has no better
home, safe construction of URLs, and lookups that cannot fail. Business rules belong in Go code that prepares the view
model. A function that returns a value of a trusted content type, such as template.HTML, bypasses escaping; see the
section on html/template below.
Nested templates
A template is a named piece of text. A Template value also carries an association: the set of other templates
that it can invoke by name with {{template "name" data}}. Associations are created in three ways:
-
{{define "name"}}...{{end}}inside the text given toParsedefines an additional template associated with the one being parsed. Definitions must appear at the top level of a template text, not inside another action. -
t.New("name")creates a new, empty template associated witht; parse text into it withParse. -
ParseFiles,ParseGlobandParseFScreate one template per file, named by the file’s base name, all associated with each other (see below).
{{block "name" pipeline}}default{{end}} is a define immediately followed by a template call. It is the tool for
layouts: the base template defines a block with default content, and a derived template set redefines the block. A
template can be associated with only one set. To use the same text in two sets, parse it twice, or Clone the set first.
package main
import (
"fmt"
"os"
"slices"
"text/template"
)
const base = `{{define "item"}}- {{.Title}} ({{.Year}}){{end -}}
{{define "list"}}{{range .}}{{template "item" .}}
{{end}}{{end -}}
{{block "header" .}}Catalogue{{end}}
{{template "list" .Books}}`
type Book struct {
Title string
Year int
}
type Page struct {
Books []Book
}
func main() {
root := template.Must(template.New("page").Parse(base))
page := Page{Books: []Book{{"Dune", 1965}, {"Emma", 1815}}}
fmt.Println("--- default block ---")
if err := root.Execute(os.Stdout, page); err != nil {
fmt.Println(err)
}
// Clone, then redefine the block: the original is unaffected.
variant := template.Must(root.Clone())
template.Must(variant.Parse(`{{define "header"}}Staff picks{{end}}`))
fmt.Println("--- variant ---")
if err := variant.Execute(os.Stdout, page); err != nil {
fmt.Println(err)
}
fmt.Println("--- ExecuteTemplate ---")
if err := root.ExecuteTemplate(os.Stdout, "item", page.Books[0]); err != nil {
fmt.Println(err)
}
fmt.Println()
names := []string{}
for _, t := range root.Templates() {
names = append(names, t.Name())
}
slices.Sort(names)
fmt.Println("templates:", names)
fmt.Println("lookup missing:", root.Lookup("nope") == nil)
err := root.ExecuteTemplate(os.Stdout, "nope", nil)
fmt.Println("error:", err)
}
--- default block ---
Catalogue
- Dune (1965)
- Emma (1815)
--- variant ---
Staff picks
- Dune (1965)
- Emma (1815)
--- ExecuteTemplate ---
- Dune (1965)
templates: [header item list page]
lookup missing: true
error: template: no template "nope" associated with template "page"
The example uses the methods that deal with the set:
-
Executeruns the template named by the value on which it is called (page),ExecuteTemplate(w, name, data)runs any associated one. Executing a name that is not in the set is an error that names the template that was asked (no template "nope" associated with template "page"). -
Templates()lists the associated templates in no particular order (the example sorts the names),Lookup(name)returns one ornil, andDefinedTemplates()returns a string such as; defined templates are: "header", "page", ...that is used to build error messages. -
Clone()copies the set. Parsing a new definition ofheaderinto the clone leaves the original untouched, which is the way to derive a variant of a layout without rebuilding it. Withhtml/template, cloning is only possible before the first execution (see below). -
Parsecan be called repeatedly on the same template. A definition whose body is only white space and comments does not replace an existing body, so a text consisting of justdefineactions adds templates without overwriting the main one.
Loading templates from files and from an embedded filesystem
ParseFiles(names...), ParseGlob(pattern) and ParseFS(fsys, patterns...) create a template per file. The template
for templates/layout.gohtml is named layout.gohtml, with directories dropped; two files with the same base name in
different directories collide and the last one wins. The returned *Template is named after the first file, and when
you call the method form, t.ParseFiles(...), t should carry one of the file names or you must call ExecuteTemplate
with the name you want. ParseFS accepts any fs.FS, so embed.FS makes the templates part of the binary and removes
the "where is the working directory?" problem of deployments; the pattern follows path.Match, so use forward slashes.
Typical conventions: the file extension is free (.tmpl, .gotmpl, .gohtml, .html); .gohtml tells editors and
readers that the file is a Go HTML template. There is no inheritance mechanism beyond block and association, so a
page-per-set structure is common, as in the cmd/shelfd example below.
Options and delimiters
Template.Option("missingkey=...") controls what happens when a map is indexed by a key it does not contain
(a missing struct field is always an error):
| Option | Behaviour |
|---|---|
|
The default. The action prints |
|
The zero value of the map’s element type. For a |
|
Execution stops with an error |
missingkey=error is the right setting for configuration and manifest generation, where a typo in a key should fail
loudly rather than produce <no value> in the output; the example in the Helm section below uses it. Option panics on
an unknown or malformed option string.
Template.Delims(left, right) changes the delimiters for subsequent Parse calls, for text that already contains
{{, such as JSON, Go source with generics-free braces, or the output of another template engine. An empty string means
the default. Nested definitions inherit the setting.
Execution errors
Parsing and execution can fail in different, specific ways. The program below provokes the common failures and prints the real messages that Go 1.27.2 produces.
package main
import (
"errors"
"fmt"
"io"
"text/template"
)
type Book struct {
Title string
secret string
Author *Author
}
type Author struct{ Name string }
func (b Book) Cover(size int) string { return fmt.Sprint("cover-", size) }
func main() {
parseCases := []string{
`{{.Title`, // unclosed action
`{{if .Title}}no end`, // missing end
`{{.Title | nosuchfunc}}`, // unknown function
`{{$x}}`, // undefined variable
`{{end}}`, // stray end
`{{break}}`, // break outside range
`{{define "a"}}{{if true}}{{define "b"}}{{end}}{{end}}{{end}}`, // define not at top level
}
for _, src := range parseCases {
_, err := template.New("p").Parse(src)
fmt.Printf("parse %-38q -> %v\n", src, err)
}
execCases := []struct {
src string
data any
}{
{`{{.Missing}}`, Book{}},
{`{{.secret}}`, Book{}},
{`{{.Author.Name}}`, Book{}},
{`{{.Title.Foo}}`, Book{Title: "x"}},
{`{{.Cover}}`, Book{}},
{`{{.Cover "big"}}`, Book{}},
{`{{eq .Title 3}}`, Book{Title: "x"}},
{`{{index .Tags 5}}`, map[string]any{"Tags": []int{1}}},
{`{{len 3}}`, nil},
{`{{template "nope"}}`, nil},
{`{{.Name}}`, map[string]any{}},
}
for _, c := range execCases {
t := template.Must(template.New("e").Parse(c.src))
err := t.Execute(io.Discard, c.data)
fmt.Printf("exec %-22s -> %v\n", c.src, err)
}
// missingkey=error turns a missing map key into an error.
t := template.Must(template.New("m").Option("missingkey=error").Parse(`{{.Name}}`))
fmt.Println("missingkey:", t.Execute(io.Discard, map[string]any{}))
// Errors wrap their cause; ExecError carries the template name.
t = template.Must(template.New("w").Funcs(template.FuncMap{
"boom": func() (int, error) { return 0, io.ErrUnexpectedEOF },
}).Parse(`{{boom}}`))
err := t.Execute(io.Discard, nil)
fmt.Println("Is EOF:", errors.Is(err, io.ErrUnexpectedEOF))
if ee, ok := errors.AsType[template.ExecError](err); ok {
fmt.Println("ExecError template:", ee.Name)
}
// template.Must panics on a parse error.
defer func() { fmt.Println("recovered:", recover()) }()
template.Must(template.New("must").Parse(`{{`))
}
parse "{{.Title" -> template: p:1: unclosed action
parse "{{if .Title}}no end" -> template: p:1: unexpected EOF
parse "{{.Title | nosuchfunc}}" -> template: p:1: function "nosuchfunc" not defined
parse "{{$x}}" -> template: p:1: undefined variable "$x"
parse "{{end}}" -> template: p:1: unexpected {{end}}
parse "{{break}}" -> template: p:1: {{break}} outside {{range}}
parse "{{define \"a\"}}{{if true}}{{define \"b\"}}{{end}}{{end}}{{end}}" -> template: p:1: unexpected <define> in command
exec {{.Missing}} -> template: e:1:2: executing "e" at <.Missing>: can't evaluate field Missing in type main.Book
exec {{.secret}} -> template: e:1:2: executing "e" at <.secret>: secret is an unexported field of struct type main.Book
exec {{.Author.Name}} -> template: e:1:9: executing "e" at <.Author.Name>: nil pointer evaluating *main.Author.Name
exec {{.Title.Foo}} -> template: e:1:8: executing "e" at <.Title.Foo>: can't evaluate field Foo in type string
exec {{.Cover}} -> template: e:1:2: executing "e" at <.Cover>: wrong number of args for Cover: want 1 got 0
exec {{.Cover "big"}} -> template: e:1:9: executing "e" at <"big">: expected integer; found "big"
exec {{eq .Title 3}} -> template: e:1:2: executing "e" at <eq .Title 3>: error calling eq: incompatible types for comparison: string and int
exec {{index .Tags 5}} -> template: e:1:2: executing "e" at <index .Tags 5>: error calling index: index out of range: 5
exec {{len 3}} -> template: e:1:2: executing "e" at <len 3>: error calling len: len of type int
exec {{template "nope"}} -> template: e:1:11: executing "e" at <{{template "nope"}}>: template "nope" not defined
exec {{.Name}} -> <nil>
missingkey: template: m:1:2: executing "m" at <.Name>: map has no entry for key "Name"
Is EOF: true
ExecError template: w
recovered: template: must:1: unclosed action
What to take from the output:
-
Syntax errors (
unclosed action,unexpected EOF, an unknown function, an undefined variable, a strayend,breakoutsiderange, adefinethat is not at the top level) are parse errors, reported byParsewith the template name and line, before any data is involved. A program that parses at start-up finds them at start-up. -
Data errors are execution errors: a missing field, an unexported field, a
nilpointer in a chain, a method called with the wrong number or type of arguments, a comparison between incompatible kinds, an index out of range,lenof a type that has no length, an unknown template. The message givestemplate:line:column, the action and the reason. Fix them by testing the template against realistic data (see the testing section). -
A map lookup with a missing key is silent by default (
{{.Name}}on an empty map succeeds) and an error withmissingkey=error. -
The error from
Executewraps the cause.errors.Isfinds the sentinel returned by a template function, anderrors.AsType[template.ExecError](err)retrieves theExecErrorvalue, which carries the templateName. Writer failures are returned unwrapped. -
template.Mustpanics with the parse error as its value;recoverreturns it.
Contextual auto-escaping with html/template
The problem
HTML is not one language but five that nest: text, tags and attributes, URLs, CSS and JavaScript. A string that is
harmless in one place is code in another: O'Reilly & <i>Sons</i> is fine as text, breaks out of a single-quoted
attribute, and ends a JavaScript string in an event handler. Escaping "for HTML" once, with a function such as
HTMLEscapeString, protects only the first of those places. The classic injection holes (cross-site scripting) come from
printing user-controlled data into a place the escaper did not consider.
html/template solves this at the root. When a template is executed for the first time, the package runs a static
analysis over the whole parse tree: it follows the text between actions through a state machine that models the HTML,
CSS and JavaScript tokenisers, so it knows, for every {{ }}, whether the value is going to be inside element text, an
attribute value (quoted or not), a URL (and which part of it), a CSS value, a JavaScript expression, string or regular
expression. It then rewrites each action to add exactly the escaping functions that context requires. The documentation
shows the effect: <a href="/search?q={{.}}">{{.}}</a> becomes
<a href="/search?q={{. | urlescaper | attrescaper}}">{{. | htmlescaper}}</a>. The template author writes plain
{{.Title}}, in every place, and the package picks the escaper. This property is called contextual autoescaping.
The same template text is run through both packages below, with a deliberately hostile title, a javascript: link and
a font name that tries to inject CSS.
package main
import (
"fmt"
htmltemplate "html/template"
"os"
texttemplate "text/template"
)
func main() {
const page = `<h1>{{.Title}}</h1>
<a href="/search?q={{.Title}}">search</a>
<a href="{{.Link}}">link</a>
<p title='{{.Title}}'>text</p>
<script>var title = {{.Title}}; var data = {{.Data}};</script>
<button onclick="open('{{.Title}}')">open</button>
<style>p { font-family: "{{.Font}}"; }</style>
`
data := struct {
Title string
Link string
Font string
Data map[string]any
}{
Title: `<script>alert("x")</script> O'Reilly & Sons`,
Link: "javascript:alert(1)",
Font: "Serif; background: url(evil)",
Data: map[string]any{"isbn": "978-0", "copies": 3},
}
fmt.Println("=== text/template (no escaping) ===")
t1 := texttemplate.Must(texttemplate.New("p").Parse(page))
if err := t1.Execute(os.Stdout, data); err != nil {
fmt.Println(err)
}
fmt.Println("=== html/template (contextual escaping) ===")
t2 := htmltemplate.Must(htmltemplate.New("p").Parse(page))
if err := t2.Execute(os.Stdout, data); err != nil {
fmt.Println(err)
}
}
=== text/template (no escaping) ===
<h1><script>alert("x")</script> O'Reilly & Sons</h1>
<a href="/search?q=<script>alert("x")</script> O'Reilly & Sons">search</a>
<a href="javascript:alert(1)">link</a>
<p title='<script>alert("x")</script> O'Reilly & Sons'>text</p>
<script>var title = <script>alert("x")</script> O'Reilly & Sons; var data = map[copies:3 isbn:978-0];</script>
<button onclick="open('<script>alert("x")</script> O'Reilly & Sons')">open</button>
<style>p { font-family: "Serif; background: url(evil)"; }</style>
=== html/template (contextual escaping) ===
<h1><script>alert("x")</script> O'Reilly & Sons</h1>
<a href="/search?q=%3cscript%3ealert%28%22x%22%29%3c%2fscript%3e%20O%27Reilly%20%26%20Sons">search</a>
<a href="#ZgotmplZ">link</a>
<p title='<script>alert("x")</script> O'Reilly & Sons'>text</p>
<script>var title = "\u003cscript\u003ealert(\"x\")\u003c/script\u003e O'Reilly \u0026 Sons"; var data = {"copies":3,"isbn":"978-0"};</script>
<button onclick="open('\u003cscript\u003ealert(\u0022x\u0022)\u003c\/script\u003e O\u0027Reilly \u0026 Sons')">open</button>
<style>p { font-family: "#ZgotmplZ"; }</style>
Source: html/template and html/template: Contexts
Read the second half of the output line by line:
-
In element text,
<,>,&and both quotes become entities:<script>alert("x")</script>. -
In the query part of a URL, every byte that is not an unreserved URL character is percent-encoded, so the title can never end the query or start a new parameter.
-
In a link that begins with the value, the URL is filtered as well as encoded:
javascript:alert(1)has a scheme other thanhttp,httpsormailto, so the output is#ZgotmplZ, a harmless placeholder that makes the problem visible in the page source. A relative URL passes. -
In a quoted attribute, the value is HTML-escaped, so a quote in the data cannot end the attribute.
-
In a
<script>element the Go value becomes a JavaScript value, not text: the string is a quoted JavaScript string with<,>and&written as\u003c,\u003eand\u0026, and the map is a JSON object. Note that the template hasvar title = {{.Title}};without quotes around the action; the package adds the quotes. Writing quotes yourself ("{{.Title}}") is also correct, and then only the string-body escaping is applied. -
In an inline event handler (
onclick), which is JavaScript inside an HTML attribute, the escaping is both: the string body is JavaScript-escaped (\u0027) and the result is attribute-safe. -
In CSS, a value that does not look like a safe token is replaced by
#ZgotmplZ, so the attacker’s; background: url(evil)never reaches the style sheet.
| Where the action is | What happens to the value |
|---|---|
Element text, quoted attribute value |
HTML entity escaping |
Unquoted attribute value |
Stricter HTML escaping that also escapes white space and |
URL, at the start of an |
Scheme filter (relative, |
URL, after a prefix such as |
Percent-encoding of everything that is not unreserved |
JavaScript expression ( |
JSON encoding, so a Go string, number, slice, map or struct becomes the equivalent JavaScript literal |
Inside a JavaScript string literal |
String escaping ( |
JavaScript regular expression |
Regexp-safe escaping (a character-class interpolation is an error) |
CSS value |
Filtered: only values that look like safe tokens pass, otherwise the output is |
Comment |
Stripped from the output, except comments carried by the typed content below |
Attributes are classified by name. A namespace prefix is ignored (my:href is treated as href), and so is a data-
prefix (data-href is a URL attribute), while an xmlns: attribute is always a URL; attributes named on... are
JavaScript and style is CSS.
Typed content: telling the package that a value is already safe
The package assumes that every value is plain text. When a value is already a fragment of the target language, for
example HTML that you produced from a sound sanitizer, escaping it would show the markup as text. The types
template.HTML, HTMLAttr, JS, JSStr, CSS, URL and Srcset are strings that carry a promise: "this is safe in
the right context; do not escape it". The package then writes the value verbatim, or just checks that it is compatible
with the context.
package main
import (
"errors"
"fmt"
"html/template"
"io"
"os"
)
func render(src string, data any) {
t := template.Must(template.New("t").Parse(src))
if err := t.Execute(os.Stdout, data); err != nil {
fmt.Printf("ERROR: %v\n", err)
return
}
fmt.Println()
}
func main() {
hostile := `<b onclick="x()">bold</b>`
fmt.Println("plain string: ")
render(`{{.}}`, hostile)
fmt.Println("template.HTML (trusted, NOT escaped):")
render(`{{.}}`, template.HTML(hostile))
fmt.Println("URL schemes:")
render(`<a href="{{.}}">x</a>`, "https://example.com/a b")
render(`<a href="{{.}}">x</a>`, "mailto:ada@example.com")
render(`<a href="{{.}}">x</a>`, "javascript:alert(1)")
render(`<a href="{{.}}">x</a>`, template.URL("javascript:alert(1)"))
fmt.Println("HTMLAttr, JS and JSStr:")
render(`<input {{.}}>`, template.HTMLAttr(`disabled`))
render(`<input {{.}}>`, `disabled onfocus=alert(1)`)
render(`<script>var x = {{.}};</script>`, template.JS(`1 + 2`))
render(`<script>var s = "{{.}}";</script>`, template.JSStr(`line\nbreak`))
fmt.Println("comments are stripped; typed HTML keeps its own:")
render("<!-- secret note --><p>{{.}}</p>", template.HTML("<!-- kept -->ok"))
names := map[template.ErrorCode]string{
template.ErrAmbigContext: "ErrAmbigContext",
template.ErrBranchEnd: "ErrBranchEnd",
template.ErrEndContext: "ErrEndContext",
template.ErrPartialEscape: "ErrPartialEscape",
template.ErrPredefinedEscaper: "ErrPredefinedEscaper",
}
fmt.Println("escaping errors surface on the first Execute:")
for _, src := range []string{
`{{if .}}<a href="{{end}}x`,
`<a href="{{if .}}/a/{{else}}/s?q={{end}}{{.}}">x</a>`,
`<div class={{. | html}}>x</div>`,
`<script>alert("\{{.}}")</script>`,
`<div title="open`,
} {
t, err := template.New("t").Parse(src)
if err == nil {
err = t.Execute(io.Discard, true)
}
if te, ok := errors.AsType[*template.Error](err); ok {
fmt.Printf("%-22s %v\n", names[te.ErrorCode], err)
} else {
fmt.Println("other:", err)
}
}
}
plain string:
<b onclick="x()">bold</b>
template.HTML (trusted, NOT escaped):
<b onclick="x()">bold</b>
URL schemes:
<a href="https://example.com/a%20b">x</a>
<a href="mailto:ada@example.com">x</a>
<a href="#ZgotmplZ">x</a>
<a href="javascript:alert%281%29">x</a>
HTMLAttr, JS and JSStr:
<input disabled>
<input ZgotmplZ>
<script>var x = 1 + 2;</script>
<script>var s = "line\nbreak";</script>
comments are stripped; typed HTML keeps its own:
<p><!-- kept -->ok</p>
escaping errors surface on the first Execute:
ErrBranchEnd html/template:t:1:5: {{if}} branches end in different contexts: {stateURL delimDoubleQuote urlPartNone jsCtxRegexp [] attrURL elementNone <nil>}, {stateText delimNone urlPartNone jsCtxRegexp [] attrNone elementNone <nil>}
ErrAmbigContext html/template:t:1:42: {{.}} appears in an ambiguous context within a URL
ErrPredefinedEscaper html/template:t:1:13: predefined escaper "html" disallowed in template
ErrPartialEscape html/template:t: unfinished escape sequence in JS string: "\\"
ErrEndContext html/template:t: ends in a non-text context: {stateAttr delimDoubleQuote urlPartNone jsCtxRegexp [] attrNone elementNone <nil>}
Source: html/template: HTML and html/template: ErrorCode
| Type | Promise made by the programmer |
|---|---|
|
A complete, balanced HTML fragment, not from a third party unless sanitized; no unclosed tags or comments |
|
One or more attributes from a trusted source, such as ` dir="ltr"` |
|
A trusted ECMAScript 5 expression ( |
|
Characters meant to go between quotes in a JavaScript expression; escape sequences are fine, line continuations are not |
|
A trusted CSS stylesheet, rule, declaration or value |
|
A trusted URL; it still goes through URL normalisation but skips the scheme filter |
|
A trusted |
The example shows what the types do and what they cost:
-
A plain string with markup is escaped; the same text converted to
template.HTMLis written verbatim, including itsonclickattribute. Converting a value totemplate.HTMLis the way to introduce an XSS hole in a Go web application: the conversion cannot be checked by the compiler, so every one of them needs a reason you could defend in a code review (usually: it was produced by a sanitizer, or by another template from this package). -
template.URL("javascript:alert(1)")suppresses the scheme filter and the browser would run it: the output keepsjavascript:(with the parentheses percent-encoded, which does not make it safe). Never convert user input. -
template.HTMLAttr("disabled")is written as an attribute; the same text as a plain string, with an injectedonfocus=..., is replaced by the harmlessZgotmplZ. -
template.JS("1 + 2")is written into the script as an expression, andtemplate.JSStrinside quotes is written verbatim. Do not usetemplate.JSfor untrusted JSON; unmarshal it withencoding/jsonand pass the Go value, which the package re-encodes safely. -
Comments in the template are stripped; a comment in
template.HTMLdata is not.
The rule of thumb: keep data as plain Go values and let the package escape it; use a typed string only for markup that your own code, or a trusted sanitizer, created. The type belongs on a function result or a view-model field, close to where the trust decision was made, and not in a place where it is sprinkled over arbitrary strings.
Escaping errors
Because the escaper must know the context of every action statically, a template whose context depends on runtime data
is rejected. The escaper runs on the first Execute, not on Parse, so these errors appear when the template is first
used. This is one more reason to render every template in a test, as shown later. The errors are *template.Error values
with an ErrorCode, the template name and, where known, the line; the code is available with errors.AsType.
ErrorCode |
Cause and remedy |
|---|---|
|
An action in a URL whose position depends on a condition ( |
|
Malformed tag syntax: an unquoted attribute with |
|
The branches of an |
|
A template (or fragment) ends inside a tag, attribute, script or other non-text context. Such a template cannot be executed directly |
|
A |
|
A recursive template that does not finish in the context it started in |
|
An action inside a JavaScript regular expression character class ( |
|
An action right after a backslash in a JavaScript string ( |
|
A |
|
A |
|
|
|
Deprecated. Formerly returned for actions inside a JavaScript template literal, which are supported now |
The "ends in a non-text context" and "branches end in different contexts" messages include the escaper’s internal state
({stateURL delimDoubleQuote ...}), as the output above shows; read them as "the template ended inside a URL in a
double-quoted attribute". A frequent real cause is a missing closing quote or > far from the action named in the error.
Lifecycle: execute once, then the set is frozen
html/template rewrites the parse tree during the first Execute. Afterwards, the set cannot change. Parse after
Execute, and Clone, AddParseTree of a template that has executed, return an error; clone and add templates before
you execute any of them.
package main
import (
"fmt"
"html/template"
"io"
"strings"
)
func main() {
t := template.Must(template.New("page").Parse(`<p>{{.}}</p>`))
// Funcs must be registered before Parse; an unknown function is a parse error.
_, err := template.New("f").Parse(`{{shout .}}`)
fmt.Println("unknown func:", err)
shout := template.FuncMap{"shout": strings.ToUpper}
f := template.Must(template.New("f").Funcs(shout).Parse(`<b>{{shout .}}</b>`))
_ = f.Execute(io.Discard, "ok")
// The first Execute runs the escaper and freezes the template set.
_ = t.Execute(io.Discard, "x")
_, err = t.Parse(`{{define "late"}}x{{end}}`)
fmt.Println("parse after execute:", err)
_, err = t.Clone()
fmt.Println("clone after execute:", err)
// A template that is never executed can still be cloned and extended.
base := template.Must(template.New("base").Parse(`{{block "b" .}}default{{end}}`))
v := template.Must(base.Clone())
template.Must(v.Parse(`{{define "b"}}variant{{end}}`))
var sb strings.Builder
_ = v.Execute(&sb, nil)
fmt.Println("variant:", sb.String())
}
unknown func: template: f:1: function "shout" not defined
parse after execute: html/template: cannot Parse after Execute
clone after execute: html/template: cannot Clone "page" after it has executed
variant: variant
Source: html/template: Template.Clone
The same example shows that Funcs must be called before Parse (an unknown function is a parse error). Because a set
cannot be cloned once it has run, per-request values such as a CSRF token or the visitor’s locale are passed in the view
model, not by cloning the executed set and adding functions per request.
Details worth knowing
-
Comments. HTML comments in a template are removed from the output. Do not put secrets or TODOs in them anyway; use
{{/* */}}, which never reaches the output of either package. -
Quoting. The unquoted attribute
<a href=/x?q={{.}}>is handled by a stricter escaper and some inputs are rejected asErrBadHTML. Always quote attribute values. -
JavaScript template literals. Actions inside backtick strings in
<script>are escaped and supported again by default. The release notes list this change under Go 1.22 and the GODEBUG history file under Go 1.23; either way,jstmpllitinterpno longer has any effect. Between Go 1.21 and that release, an action inside a template literal was an error (ErrJSTemplate). -
<meta>refresh URLs (Go 1.27). A URL in thecontentattribute of a<meta http-equiv="refresh">is now escaped and filtered like a URL. The behaviour was added as a security fix and backported to Go 1.25.8 and 1.26.1. The setting ishtmlmetacontenturlescape;GODEBUG=htmlmetacontenturlescape=0restores the old behaviour. The documentation text for the setting spells its defaulthtmlmetacontentescape=1, but the name in the source and in thegodebugstable ishtmlmetacontenturlescape. The example below was run with and without the setting. -
JSON in script blocks. A Go struct, map or slice in a
<script>becomes a JavaScript literal throughencoding/json, so tags such asjson:"isbn"apply there too (see encoding/json). For a JSON data island, prefer<script type="application/json" id="data">{{.Data}}</script>and parse it in JavaScript; the package encodes the value as JSON there too, and writes</script>inside a string as\u003c/script\u003e. -
Manual escaping functions.
template.HTMLEscapeString,JSEscapeString,URLQueryEscaperand friends exist for code outside templates (for example when writing an error message in a handler). Insidehtml/template, do not combine them with the automatic escaping: the result is escaped twice.
package main
import (
"fmt"
"html/template"
"os"
)
func main() {
t := template.Must(template.New("m").Parse(
`<meta http-equiv="refresh" content="5; url={{.}}">` + "\n"))
for _, u := range []string{"https://example.com/shelf?a=1&b=2", "javascript:alert(1)"} {
if err := t.Execute(os.Stdout, u); err != nil {
fmt.Println(err)
}
}
}
<!-- default -->
<meta http-equiv="refresh" content="5; url=https://example.com/shelf?a=1&b=2">
<meta http-equiv="refresh" content="5; url=#ZgotmplZ">
<!-- GODEBUG=htmlmetacontenturlescape=0 -->
<meta http-equiv="refresh" content="5; url=https://example.com/shelf?a=1&b=2">
<meta http-equiv="refresh" content="5; url=javascript:alert(1)">
The first run uses the default; the second sets GODEBUG=htmlmetacontenturlescape=0 in the environment. A program can set
the same default permanently with a //go:debug htmlmetacontenturlescape=0 line in a package main file, and a module
with go 1.27 gets the new behaviour automatically, because defaults follow the go line in go.mod (see
Toolchain Versions and Compatibility).
What html/template does not do
-
It does not sanitize HTML. It escapes strings; it cannot make a piece of untrusted markup (a forum post with allowed
<b>tags) safe. That needs a sanitizer, whose output you may then marktemplate.HTML. -
It does not validate where a URL points.
href="{{.Next}}"with an attacker-controlled absolutehttps:URL is accepted; open-redirect checks belong in the handler. -
It trusts the template author. Anyone who can edit the template text can write a script tag; templates are source code.
-
It does not set a Content Security Policy, CSRF tokens, cookies or any HTTP header. Those belong to the handler and middleware (see net/http Clients and Servers, CORS and the discussion of cross-site request forgery in Axum CSRF and security hardening, whose concepts transfer). A nonce for a CSP
script-srcis an ordinary string field on the view model. -
It does not protect output that is not HTML. For JSON responses use
encoding/json; for plain text responsestext/templateis correct, andhtml/templatewould escape characters that should stay literal.
The shelf catalogue as HTML
The cmd/shelfd service from the running scenario answers JSON on GET /books. This section adds the human-readable
side: an HTML index of the catalogue and a page per book, built from three template files embedded in the binary. The
layout defines the page frame with two blocks, title and content; each page file defines those two blocks.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{block "title" .}}Shelf{{end}} - Shelf</title>
</head>
<body>
<nav><a href="/">All books</a></nav>
<main>
{{block "content" .}}{{end}}
</main>
<footer>{{len .Books}} book(s) in the catalogue</footer>
</body>
</html>
{{define "title"}}All books{{end -}}
{{define "content" -}}
<h1>Catalogue</h1>
<ul>
{{- range .Books}}
<li><a href="/books/{{.ISBN}}">{{.Title}}</a>{{with .Authors}} by {{range $i, $a := .}}{{if $i}}, {{end}}{{$a.Name}}{{end}}{{end}}</li>
{{- else}}
<li>The catalogue is empty.</li>
{{- end}}
</ul>
{{- end}}
{{define "title"}}{{.Book.Title}}{{end -}}
{{define "content" -}}
<h1>{{.Book.Title}}</h1>
<p>ISBN {{.Book.ISBN}}</p>
<p>Status: {{.State}}</p>
{{- end}}
Because every page defines a block named content, the pages cannot live in a single set (the last definition would
win). The program therefore builds one set per page, each from the layout plus that page’s file, with ParseFS, and
executes layout.gohtml of the selected set. The view model for the book page embeds the model of the layout (so the
footer’s .Books works) and adds the book.
package main
import (
"bytes"
"embed"
"fmt"
"html/template"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"slices"
"strings"
"example.com/shelf/catalog"
)
//go:embed templates/*.gohtml
var templateFS embed.FS
// pages maps a page name to a template set: the shared layout plus one page.
type pages map[string]*template.Template
func loadPages() (pages, error) {
p := pages{}
for _, name := range []string{"books", "book"} {
t, err := template.ParseFS(templateFS,
"templates/layout.gohtml", "templates/"+name+".gohtml")
if err != nil {
return nil, fmt.Errorf("parse %s: %w", name, err)
}
p[name] = t
}
return p, nil
}
type server struct {
store catalog.Store
pages pages
}
// render executes into a buffer first, so a failing template never sends a
// half-written page with a 200 status.
func (s *server) render(w http.ResponseWriter, r *http.Request, status int, page string, data any) {
var buf bytes.Buffer
if err := s.pages[page].ExecuteTemplate(&buf, "layout.gohtml", data); err != nil {
slog.Error("render", "page", page, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(status)
_, _ = w.Write(buf.Bytes())
}
type catalogView struct{ Books []catalog.Book }
// view returns the whole catalogue sorted by title, so pages are stable.
func (s *server) view() catalogView {
books := s.store.All()
slices.SortFunc(books, func(a, b catalog.Book) int {
return strings.Compare(a.Title, b.Title)
})
return catalogView{books}
}
type bookView struct {
catalogView
Book catalog.Book
State catalog.LoanState
}
func (s *server) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
s.render(w, r, http.StatusOK, "books", s.view())
})
mux.HandleFunc("GET /books/{isbn}", func(w http.ResponseWriter, r *http.Request) {
isbn, err := catalog.ParseISBN(r.PathValue("isbn"))
if err != nil {
http.NotFound(w, r)
return
}
b, ok := s.store.Get(isbn)
if !ok {
http.NotFound(w, r)
return
}
s.render(w, r, http.StatusOK, "book", bookView{s.view(), b, catalog.Available})
})
return mux
}
func main() {
p, err := loadPages()
if err != nil {
slog.Error("templates", "err", err)
os.Exit(1)
}
store := catalog.NewMemStore()
for _, b := range []catalog.Book{
{ISBN: "9780306406157", Title: "Algebra <& Geometry>", Authors: []catalog.Author{{Name: "Ada"}, {Name: "Bo \"B\" Li"}}},
{ISBN: "9780131103627", Title: "Systems", Authors: nil},
} {
store.Put(b)
}
h := (&server{store: store, pages: p}).routes()
for _, target := range []string{"/", "/books/9780306406157", "/books/9780000000000", "/books/not-an-isbn"} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil))
res := rec.Result()
body, _ := io.ReadAll(res.Body)
fmt.Printf("=== GET %s -> %d %s\n", target, res.StatusCode, res.Header.Get("Content-Type"))
if res.StatusCode == http.StatusOK {
fmt.Print(string(body))
}
}
}
=== GET / -> 200 text/html; charset=utf-8
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>All books - Shelf</title>
</head>
<body>
<nav><a href="/">All books</a></nav>
<main>
<h1>Catalogue</h1>
<ul>
<li><a href="/books/9780306406157">Algebra <& Geometry></a> by Ada, Bo "B" Li</li>
<li><a href="/books/9780131103627">Systems</a></li>
</ul>
</main>
<footer>2 book(s) in the catalogue</footer>
</body>
</html>
=== GET /books/9780306406157 -> 200 text/html; charset=utf-8
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Algebra <& Geometry> - Shelf</title>
</head>
<body>
<nav><a href="/">All books</a></nav>
<main>
<h1>Algebra <& Geometry></h1>
<p>ISBN 9780306406157</p>
<p>Status: available</p>
</main>
<footer>2 book(s) in the catalogue</footer>
</body>
</html>
=== GET /books/9780000000000 -> 404 text/plain; charset=utf-8
=== GET /books/not-an-isbn -> 404 text/plain; charset=utf-8
Source: html/template: ParseFS, embed and net/http/httptest
Design points, in the order of the code:
-
//go:embed templates/*.gohtmlputs the files in the binary;ParseFSreads them from theembed.FSwith a glob (see Directives, Code Generation and Embedding). In development,template.ParseFS(os.DirFS("templates"), ...)re-reads the files from disk, so a rebuild is not needed. -
loadPagesparses every template at start-up and returns an error, whichmainreports and exits on. A page that does not parse should never be discovered by the first visitor. -
renderexecutes into abytes.Bufferfirst and writes to thehttp.ResponseWriteronly when the template succeeded. Executing straight into the response would send200 OKand half a page, followed by an error that cannot change the status any more. The cost is one buffer per response; async.Poolof buffers removes it if profiling ever shows it. -
The handler sets
Content-Type: text/html; charset=utf-8andX-Content-Type-Options: nosniffitself;html/templatedoes not touch headers. -
The list is sorted before it reaches the template, because
MemStore.Alliterates a map. Sorting belongs in Go code, not in the template. -
The route patterns are the Go 1.22
ServeMuxpatterns:GET /{$}matches only the root,GET /books/{isbn}captures a path value that the handler validates withcatalog.ParseISBNbefore looking it up; an unknown or malformed ISBN gets a 404 without ever reaching a template. -
The hostile-looking title
Algebra <& Geometry>and the author nameBo "B" Liare escaped in element text and in the<title>element, which has its own (RCDATA) context, with no code in the template.
Testing templates
A template is code with no compiler, so tests are the safety net: render every page against realistic and hostile data
and assert on the output. The test below parses every template (the test fails when loadPages does not), checks that
the escaping errors from the previous section cannot occur (they appear on the first execution), and asserts that a title
holding a <script> tag never reaches the output unescaped.
package main
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"example.com/shelf/catalog"
)
func TestPagesEscapeTitles(t *testing.T) {
p, err := loadPages() // fails the test run if any template does not parse
if err != nil {
t.Fatal(err)
}
store := catalog.NewMemStore()
store.Put(catalog.Book{ISBN: "9780306406157", Title: `<script>alert(1)</script>`})
h := (&server{store: store, pages: p}).routes()
for _, target := range []string{"/", "/books/9780306406157"} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil))
body := rec.Body.String()
if rec.Code != http.StatusOK {
t.Fatalf("%s: status %d", target, rec.Code)
}
if strings.Contains(body, "<script>") {
t.Errorf("%s: raw <script> in output:\n%s", target, body)
}
if !strings.Contains(body, "<script>alert(1)</script>") {
t.Errorf("%s: escaped title missing", target)
}
}
}
=== RUN TestPagesEscapeTitles
--- PASS: TestPagesEscapeTitles (0.00s)
PASS
ok example.com/shelf/ex/e08 0.096s
Source: net/http/httptest and testing
For larger pages, compare against a golden file in testdata/ (the -update flag convention is described in
Testing). Assert on structure, not on the white space of the whole page, or
the test breaks every time someone reformats a template. When the page also has to work in a browser, drive it with
the end-to-end approach in end-to-end testing in the browser.
Templates and trust
text/template and html/template assume that the author of the template is trusted and that the data may not be.
These are different questions:
-
The data in
html/templateis escaped, so a visitor who types<script>into a form cannot run it. -
The template text is code. A template can read every exported field of its data, call every exported method (with arguments), and run every function in the
FuncMap. If untrusted users can write templates (an admin-editable e-mail template, a user-defined report layout), those users can read whatever the data exposes and trigger whatever its methods do. Thetext/templatedocumentation says plainly that its security model assumes trusted template authors, and that it does not auto-escape.
package main
import (
"fmt"
"os"
"strings"
"sync"
"text/template"
)
// Session is data handed to a template. Everything exported is reachable.
type Session struct {
User string
APIToken string // placeholder value; never put real secrets in template data
}
func (s *Session) Logout() string { return "logged out " + s.User }
var greeting = template.Must(template.New("greeting").Parse("hello {{.User}}\n"))
func main() {
// 1. A parsed template is safe for concurrent Execute calls; only the
// shared writer needs protection.
var (
wg sync.WaitGroup
mu sync.Mutex
out strings.Builder
)
for _, name := range []string{"ada", "bo", "cy"} {
wg.Go(func() {
var local strings.Builder
_ = greeting.Execute(&local, &Session{User: name})
mu.Lock()
out.WriteString(local.String())
mu.Unlock()
})
}
wg.Wait()
fmt.Println(len(strings.Split(strings.TrimSpace(out.String()), "\n")), "greetings rendered")
// 2. A template written by an untrusted user is code: it can read every
// exported field and call every exported method of its data.
userTemplate := "token={{.APIToken}} {{.Logout}}\n"
t := template.Must(template.New("user").Parse(userTemplate))
_ = t.Execute(os.Stdout, &Session{User: "ada", APIToken: "EXAMPLE-NOT-A-REAL-TOKEN"})
}
3 greetings rendered
token=EXAMPLE-NOT-A-REAL-TOKEN logged out ada
Source: text/template: security model and sync: WaitGroup.Go
The remedies are structural: pass a small, purpose-built view model that contains only what the template should see;
give it no methods with side effects (Logout above is the anti-example); never put secrets, tokens or database handles
in template data; and, for user-written templates, use a deliberately limited language such as a placeholder substitution
rather than text/template.
The first half of the example shows the concurrency rule. A parsed template can be executed from many goroutines at the
same time, because execution does not mutate it; the only shared mutable thing is the writer, which here is guarded by a
mutex (and, in a web server, is a per-request ResponseWriter). Template construction (New, Parse, Funcs,
Clone) must not run concurrently with other uses of the same set. wg.Go (Go 1.25) starts a goroutine and counts it in
the wait group in one call; because Go 1.22 each iteration of for range has its own name, so the closure is correct.
Generating source code and other text
text/template is also the tool behind many go generate programs, because it separates the shape of the output from
the data that fills it. The example generates a String method for the LoanState enum of the shelf module, runs
the result through go/format (so the template does not need to be pretty, and the generated code is gofmt-clean), and
shows Delims on text that already contains braces.
package main
import (
"bytes"
"fmt"
"go/format"
"os"
"text/template"
)
// A tiny code generator: one String method per enum, formatted with go/format.
var enumTmpl = template.Must(template.New("enum").Parse(`// Code generated by gen; DO NOT EDIT.
package {{.Package}}
func (s {{.Type}}) String() string {
switch s {
{{- range .Values}}
case {{.Name}}:
return {{printf "%q" .Text}}
{{- end}}
}
return "{{.Type}}(unknown)"
}
`))
type enum struct {
Package, Type string
Values []struct{ Name, Text string }
}
func main() {
e := enum{Package: "catalog", Type: "LoanState", Values: []struct{ Name, Text string }{
{"Available", "available"}, {"OnLoan", "on loan"}, {"Overdue", "overdue"},
}}
var buf bytes.Buffer
if err := enumTmpl.Execute(&buf, e); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
src, err := format.Source(buf.Bytes())
if err != nil {
fmt.Fprintln(os.Stderr, "generated code does not parse:", err)
os.Exit(1)
}
os.Stdout.Write(src)
// Custom delimiters avoid clashes with text that already uses braces.
d := template.Must(template.New("d").Delims("<<", ">>").Parse(
`{"name": "<<.Name>>", "tags": {{not a template}}}` + "\n"))
_ = d.Execute(os.Stdout, map[string]string{"Name": "shelf"})
}
Source: text/template: Template.Delims and go/format: Source
// Code generated by gen; DO NOT EDIT.
package catalog
func (s LoanState) String() string {
switch s {
case Available:
return "available"
case OnLoan:
return "on loan"
case Overdue:
return "overdue"
}
return "LoanState(unknown)"
}
{"name": "shelf", "tags": {{not a template}}}
Source: text/template, go/format and The Go Blog: Generating code
Guidelines for generators: start the output with the Code generated ... DO NOT EDIT. line that tools recognise (see
Directives, Code Generation and Embedding);
fail the generator, not the build, when the generated source does not parse (format.Source returns an error with the
position); and use printf "%q" to put strings into generated Go code, so quotes and backslashes are right. The
stringer tool and go generate are described on that page.
Go templates outside Go: Helm and kubectl
The Go template language is used well beyond Go programs, because text/template is easy to embed. The two most common
places for an infrastructure engineer are Helm and kubectl; both have their own pages in this site, and this page
explains the language underneath them.
-
Helm renders every file in a chart’s
templates/directory withtext/templateplus a function library, then applies the result. The constructs you saw in this page are the ones in a chart:{{define}}and{{template}}in_helpers.tpl,{{range}}and{{with}}for lists and optional blocks,{{if}}, pipelines, trim markers ({{-is essential in YAML, where indentation is structure), and.Values,.Releaseand.Chartas the data. Helm adds functions (include,toYaml,nindent,default,required,tpl, and a large set of string and list helpers) that are not part of the standard library; they are not available in plaintext/template. See Helm, especially "The template language". -
kubectl offers
-o go-template='...'and-o go-template-file: the object (or list) is decoded from JSON and your template is executed with it, so field names are the lower-case JSON keys (.items,.metadata.name). See kubectl essentials, "Output formats".
The following program implements a miniature of both with only the standard library: Helm-style helpers
(include, nindent, default, required) written as a FuncMap, a deployment template that uses them,
missingkey=error, and a kubectl-style template run over a JSON pod list. It shows what those tools do under the
hood and why their error messages look like the ones in the execution-errors section.
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"text/template"
)
const helpers = `{{- define "shelf.labels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}`
const manifest = `apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}
labels:
{{- include "shelf.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount | default 1 }}
template:
spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ required "image.repository is required" .Values.image.repository }}:{{ .Values.image.tag | default "latest" }}"
`
func main() {
t := template.New("chart")
t.Funcs(template.FuncMap{
// include renders a named template and returns it as a string,
// so the result can be piped; text/template's {{template}}
// action cannot be used in a pipeline.
"include": func(name string, data any) (string, error) {
var buf bytes.Buffer
err := t.ExecuteTemplate(&buf, name, data)
return buf.String(), err
},
"nindent": func(n int, s string) string {
pad := strings.Repeat(" ", n)
return "\n" + pad + strings.ReplaceAll(s, "\n", "\n"+pad)
},
"default": func(def, v any) any {
if truth, _ := template.IsTrue(v); truth {
return v
}
return def
},
"required": func(msg string, v any) (any, error) {
if truth, _ := template.IsTrue(v); !truth {
return nil, errors.New(msg)
}
return v, nil
},
})
template.Must(t.Parse(helpers))
template.Must(t.New("deployment.yaml").Option("missingkey=error").Parse(manifest))
render := func(image map[string]any) {
values := map[string]any{
"Release": map[string]any{"Name": "prod"},
"Chart": map[string]any{"Name": "catalog-api"},
"Values": map[string]any{"replicaCount": 0, "image": image},
}
var buf bytes.Buffer
if err := t.ExecuteTemplate(&buf, "deployment.yaml", values); err != nil {
fmt.Println("error:", err)
return
}
fmt.Print(buf.String())
}
repo := "registry.example.com/shelf/catalog-api"
render(map[string]any{"repository": repo, "tag": "1.4.0"})
fmt.Println("---")
render(map[string]any{"repository": "", "tag": "1.4.0"}) // required fails
render(map[string]any{"repository": repo}) // missing key fails
// kubectl -o go-template runs the expression you give it against the
// object decoded from JSON, a map[string]any, so keys are JSON names.
const podList = `{"items":[
{"metadata":{"name":"catalog-api-1"},"status":{"phase":"Running"}},
{"metadata":{"name":"catalog-api-2"},"status":{"phase":"Pending"}}]}`
var obj map[string]any
if err := json.Unmarshal([]byte(podList), &obj); err != nil {
panic(err)
}
k := template.Must(template.New("kubectl").Parse(
`{{range .items}}{{.metadata.name}}{{"\t"}}{{.status.phase}}{{"\n"}}{{end}}`))
fmt.Println("---")
if err := k.Execute(os.Stdout, obj); err != nil {
fmt.Println("error:", err)
}
}
apiVersion: apps/v1
kind: Deployment
metadata:
name: prod-catalog-api
labels:
app.kubernetes.io/name: catalog-api
app.kubernetes.io/instance: prod
spec:
replicas: 1
template:
spec:
containers:
- name: catalog-api
image: "registry.example.com/shelf/catalog-api:1.4.0"
---
error: template: deployment.yaml:13:21: executing "deployment.yaml" at <required "image.repository is required" .Values.image.repository>: error calling required: image.repository is required
error: template: deployment.yaml:13:99: executing "deployment.yaml" at <.Values.image.tag>: map has no entry for key "tag"
---
catalog-api-1 Running
catalog-api-2 Pending
Source: text/template, Helm Docs: Chart Template Guide and kubectl get
Things the example makes concrete:
-
includeis a function, not an action, which is why{{include "shelf.labels" . | nindent 4}}can be piped, while{{template "shelf.labels" .}}cannot. The function is a closure over the template set that callsExecuteTemplateand returns the text; Helm provides itsincludein the same spirit. -
nindent 4writes a newline and indents every line; YAML is whitespace-sensitive, and most Helm template bugs are an indentation or trim-marker slip. -
requiredanddefaultlook at truthiness: areplicaCountof0is empty, sodefault 1replaced it. Remember the table of true and false values when a value of zero or an empty string has meaning. -
With
missingkey=error, a key that is absent from the values map (tag) stops the render, beforedefaultcan supply a fallback; with an empty-but-present key (repository: ""),requiredproduced its own message. Helm’srequiredand defaults work on keys that exist invalues.yaml, which is why charts declare every value there. -
kubectlruns the template with the decoded JSON, amap[string]anytree, so.itemsand.status.phaseare map keys. A missing key is silent by default, which is the same trade-off asmissingkey=default; the flag--allow-missing-template-keys=falseofkubectl getturns it into an error.
The Kubernetes operators page, Custom Resources and Operators, uses Go for controllers; the templates above are about manifests, which are data, whereas controllers build objects as typed Go structs and never need a template.
Practical guidance
-
Parse once, execute often. Parse at start-up into package-level variables or a struct, as the server above does; never call
Parseper request, which repeats the escaping analysis and allocates the parse tree every time. -
Fail early. Use
template.Mustor return the error fromloadPagesbefore listening on a port. Render every template in a test to trigger the first-execution escaping errors. -
Buffer the output of a page so that a failure can still produce a 500 page; flush it on success.
-
Pass a view model, not the domain object. A struct with exactly the fields the page shows keeps methods with side effects and secrets out of reach and lets Go code (not the template) do sorting, formatting and decisions.
-
Keep logic out of templates. A
rangewith anifand a few formatting functions is fine; if a template needs aFuncMapwith a dozen functions that call the database, the view model is too thin. -
Name templates and blocks by purpose and keep one concern per file; a
partials/convention ({{template "partials/pager" .Pager}}) works withParseFSglobs such astemplates/*.gohtmlandtemplates/partials/*.gohtml. -
Use
html/templatefor everything that ends in a browser, including e-mail HTML bodies; usetext/templatefor the plain-text alternative of the same e-mail. -
Third-party libraries. Type-checked template compilers and component libraries for Go exist outside the standard library; the standard packages remain the baseline and are what Helm and
kubectluse. This page does not cover third-party modules.
Common pitfalls
| Pitfall | What happens and what to do |
|---|---|
Using |
No escaping; the page is open to XSS. Import |
Converting user input to |
Disables escaping for that value; the conversion is where the vulnerability is |
Forgetting that |
|
Executing straight into the |
A failure in the middle of the page leaves a truncated response with status 200; render into a buffer |
Lower-case field names in the view model |
|
A zero struct or |
Always true; test a method such as |
A |
The variable is the element (not the index, as in Go code) |
Dot changed inside |
|
|
|
Relying on |
Hidden typo in a map key; use |
Parsing templates in the request path |
Slow and repeats the escaper work; parse once at start-up |
Two files with the same base name in different directories |
The last one wins in |
Cloning or parsing an |
|
Putting an action inside an HTML construct that changes context across a branch |
|
Exposing a method with side effects on template data |
An untrusted template can call it; keep view models passive |
Version notes
| Release | Change |
|---|---|
Go 1.18 |
|
Go 1.21 |
An action inside a JavaScript template literal is an error in |
Go 1.22 / 1.23 |
JavaScript template literals may again contain actions, and |
Go 1.23 |
|
Go 1.24 |
|
Go 1.25 |
|
Go 1.26 |
|
Go 1.27 |
|
References
-
Package text/template and Package html/template, including the sections Actions, Pipelines, Functions, Nested template definitions and Security model
-
Package text/template/parse, embed, io/fs, net/http/httptest, go/format, errors.AsType and sync.WaitGroup.Go