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

"text/template"

"html/template"

Output

Anything; copied verbatim

HTML fragments, safe against code injection

Escaping

None; values are printed as fmt.Print would

Contextual: HTML text, attribute, URL, JavaScript and CSS each get their own escaper

API

New, Parse, Execute, ExecuteTemplate, Funcs, Option, Must, ParseFS, …​

Same names and behaviour, plus the content types (HTML, URL, JS, …​) and Error/ErrorCode

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.

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

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 method Greeting of the value m. 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 and Execute returns the error. Output produced before the failure has already been written: for Bob the writer received You 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.

  • Execute returns an error for a failing io.Writer as 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

{{.Title}}

Field Title of a struct (the field must be exported)

{{.Authors}}

A field that is a slice, map or any other value

{{.Stock.go}}

Key go of a map. Map keys written this way must be identifiers; they need not start with an upper-case letter

{{.Author.Name}}

Chained fields and keys, to any depth; pointers are dereferenced automatically

{{.Greeting}}

A method with no arguments (called)

{{.Remaining 4}}

A method with arguments (the call can be the last element of a chain)

{{$.Title}}

Field of the root data, even inside a range

{{index .Stock "go"}}

The index function, for keys that are not identifiers or that are computed

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

{{/* comment */}}

A comment; discarded. It must start and end at the delimiters and does not nest

{{pipeline}}

Prints the value of the pipeline, as fmt.Print would (escaped contextually in html/template)

{{if pipeline}} T1 {{end}}

Runs T1 when the value of the pipeline is not empty

{{if pipeline}} T1 {{else}} T0 {{end}}

With a fallback branch

{{if p1}} T1 {{else if p2}} T2 {{else}} T0 {{end}}

A chain of conditions, equivalent to nested if actions

{{range pipeline}} T1 {{end}}

Runs T1 once for each element, with dot set to the element

{{range pipeline}} T1 {{else}} T0 {{end}}

Runs T0 when there are no elements; dot is unchanged inside T0

{{break}} and {{continue}}

Leave the innermost range, or skip to its next iteration (Go 1.18)

{{with pipeline}} T1 {{end}}

Runs T1 with dot set to the value when it is not empty

{{with pipeline}} T1 {{else with p2}} T2 {{else}} T0 {{end}}

A chain of with actions (Go 1.23)

{{define "name"}} T {{end}}

Defines an associated template; only at the top level of a template text

{{template "name"}} and {{template "name" pipeline}}

Runs the named template with nil data, or with dot set to the pipeline

{{block "name" pipeline}} T {{end}}

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>

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 with Shelf.

  • {{.Name | printf "%q"}} is a pipeline (see below) that quotes the name.

  • {{- else if eq (len .Books) 1}} shows that an if chain is not "one big condition": the not .Books test is false, and eq (len .Books) 1 is false too, so the plain else ran. The branch printed 5 books.

  • {{with $b.Authors}} by {{(index . 0).Name}}{{else}} (anonymous){{end}}: inside with, dot is the author slice; an empty slice, and nil, take the else branch. Parentheses group index . 0 so that .Name applies to its result.

  • {{with .Owner}}...{{else with .Notes}}...{{else}}... is the Go 1.23 chain: the owner is a nil pointer, so the second with ran 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 the range else for the empty case.

  • {{range 3}} iterates over 0 1 2, and {{range .Titles}} ranges over a method that returns an iter.Seq[string], where continue skipped Skip me and break ended the loop at Stop, so Never shown is never visited. Both forms need Go 1.24 or later.

  • The notes were printed in sorted key order (floor before room), although the Go map literal lists room first.

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

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:     &lt;b&gt;&amp;&lt;/b&gt; 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

The predefined functions

Function Description

and, or, not

Boolean logic over truthiness. and returns its first empty argument, or the last; or returns its first non-empty argument, or the last. Arguments are evaluated left to right and evaluation stops as soon as the result is known (since Go 1.18)

eq, ne, lt, le, gt, ge

Comparisons. eq alone accepts more than two arguments ({{eq .Kind "a" "b"}} means Kind == "a" || Kind == "b"), but all arguments are evaluated. Operands must be of comparable basic kinds; any signed or unsigned integers compare with each other, but an integer and a string, or an int and a float32, are an error

len

Length of a string, array, slice, map or channel

index

{{index x 1 2 3}} is x[1][2][3] for maps, slices and arrays; an out-of-range index is an error

slice

{{slice x 1 2}} is x[1:2]; {{slice x}} is x[:]; three indexes give a full slice expression; for strings, slices and arrays

print, printf, println

fmt.Sprint, fmt.Sprintf, fmt.Sprintln. Note that Sprint adds spaces between operands when neither is a string, which is why {{print "a" 1 2 "b"}} printed a1 2b

call

Calls a function-valued field or map entry: {{call .Format 3}}

html, js, urlquery

Escape the text of their arguments for HTML, JavaScript and URL queries. They exist in text/template for manual escaping; in html/template they are unavailable except as the last command of a pipeline, because the package escapes automatically

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 to Parse defines 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 with t; parse text into it with Parse.

  • ParseFiles, ParseGlob and ParseFS create 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:

  • Execute runs 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 or nil, and DefinedTemplates() 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 of header into the clone leaves the original untouched, which is the way to derive a variant of a layout without rebuilding it. With html/template, cloning is only possible before the first execution (see below).

  • Parse can 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 just define actions 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

missingkey=default (or invalid)

The default. The action prints <no value> in text/template; in html/template it prints an empty string

missingkey=zero

The zero value of the map’s element type. For a map[string]any the zero value is a nil interface, so text/template still prints <no value>

missingkey=error

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 stray end, break outside range, a define that is not at the top level) are parse errors, reported by Parse with 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 nil pointer in a chain, a method called with the wrong number or type of arguments, a comparison between incompatible kinds, an index out of range, len of a type that has no length, an unknown template. The message gives template: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 with missingkey=error.

  • The error from Execute wraps the cause. errors.Is finds the sentinel returned by a template function, and errors.AsType[template.ExecError](err) retrieves the ExecError value, which carries the template Name. Writer failures are returned unwrapped.

  • template.Must panics with the parse error as its value; recover returns 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>&lt;script&gt;alert(&#34;x&#34;)&lt;/script&gt; O&#39;Reilly &amp; 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='&lt;script&gt;alert(&#34;x&#34;)&lt;/script&gt; O&#39;Reilly &amp; 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>

Read the second half of the output line by line:

  • In element text, <, >, & and both quotes become entities: &lt;script&gt;alert(&#34;x&#34;)&lt;/script&gt;.

  • 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 than http, https or mailto, 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, \u003e and \u0026, and the map is a JSON object. Note that the template has var 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 =; quote your attributes anyway

URL, at the start of an href, src, action, …​

Scheme filter (relative, http, https and mailto pass; anything else becomes #ZgotmplZ), then URL normalisation

URL, after a prefix such as /search?q=

Percent-encoding of everything that is not unreserved

JavaScript expression (<script>, onclick, …​)

JSON encoding, so a Go string, number, slice, map or struct becomes the equivalent JavaScript literal

Inside a JavaScript string literal

String escaping (\u003c, \u0027, …​) of the body

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 #ZgotmplZ; CSS strings are escaped

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:
&lt;b onclick=&#34;x()&#34;&gt;bold&lt;/b&gt;
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>}
Type Promise made by the programmer

template.HTML

A complete, balanced HTML fragment, not from a third party unless sanitized; no unclosed tags or comments

template.HTMLAttr

One or more attributes from a trusted source, such as ` dir="ltr"`

template.JS

A trusted ECMAScript 5 expression ((x + y)), with no statement/expression ambiguity

template.JSStr

Characters meant to go between quotes in a JavaScript expression; escape sequences are fine, line continuations are not

template.CSS

A trusted CSS stylesheet, rule, declaration or value

template.URL

A trusted URL; it still goes through URL normalisation but skips the scheme filter

template.Srcset

A trusted srcset attribute value

The example shows what the types do and what they cost:

  • A plain string with markup is escaped; the same text converted to template.HTML is written verbatim, including its onclick attribute. Converting a value to template.HTML is 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 keeps javascript: (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 injected onfocus=..., is replaced by the harmless ZgotmplZ.

  • template.JS("1 + 2") is written into the script as an expression, and template.JSStr inside quotes is written verbatim. Do not use template.JS for untrusted JSON; unmarshal it with encoding/json and pass the Go value, which the package re-encodes safely.

  • Comments in the template are stripped; a comment in template.HTML data 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

ErrAmbigContext

An action in a URL whose position depends on a condition (href="{{if .C}}/a/{{else}}/s?q={{end}}{{.X}}"). Move the action into each branch

ErrBadHTML

Malformed tag syntax: an unquoted attribute with <, href = /x, a typo in a tag name. Quote all attributes

ErrBranchEnd

The branches of an if, range or with end in different contexts ({{if .C}}<a href="{{end}}). Put whole tags inside the branches

ErrEndContext

A template (or fragment) ends inside a tag, attribute, script or other non-text context. Such a template cannot be executed directly

ErrNoSuchTemplate

A template call whose target is not defined when the caller is first escaped

ErrOutputContext

A recursive template that does not finish in the context it started in

ErrPartialCharset

An action inside a JavaScript regular expression character class (/foo[{{.}}]/). Not supported

ErrPartialEscape

An action right after a backslash in a JavaScript string ("\{{.X}}"). Write "{{.X}}" and pass template.JSStr for a deliberate partial escape

ErrRangeLoopReentry

A range whose second iteration ends in another context than the first, typically a missing quote in a script

ErrSlashAmbig

A / in JavaScript that could be division or the start of a regular expression, depending on a branch. Add semicolons or parentheses

ErrPredefinedEscaper

html or urlquery used in the middle of a pipeline. Remove it; the package already escapes

ErrJSTemplate

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

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 as ErrBadHTML. 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, jstmpllitinterp no 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 the content attribute 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 is htmlmetacontenturlescape; GODEBUG=htmlmetacontenturlescape=0 restores the old behaviour. The documentation text for the setting spells its default htmlmetacontentescape=1, but the name in the source and in the godebugs table is htmlmetacontenturlescape. 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 through encoding/json, so tags such as json:"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, URLQueryEscaper and friends exist for code outside templates (for example when writing an error message in a handler). Inside html/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&amp;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&amp;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 mark template.HTML.

  • It does not validate where a URL points. href="{{.Next}}" with an attacker-controlled absolute https: 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-src is 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 responses text/template is correct, and html/template would 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 &lt;&amp; Geometry&gt;</a> by Ada, Bo &#34;B&#34; 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 &lt;&amp; Geometry&gt; - Shelf</title>
</head>
<body>
  <nav><a href="/">All books</a></nav>
  <main>
<h1>Algebra &lt;&amp; Geometry&gt;</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

Design points, in the order of the code:

  • //go:embed templates/*.gohtml puts the files in the binary; ParseFS reads them from the embed.FS with 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.

  • loadPages parses every template at start-up and returns an error, which main reports and exits on. A page that does not parse should never be discovered by the first visitor.

  • render executes into a bytes.Buffer first and writes to the http.ResponseWriter only when the template succeeded. Executing straight into the response would send 200 OK and half a page, followed by an error that cannot change the status any more. The cost is one buffer per response; a sync.Pool of buffers removes it if profiling ever shows it.

  • The handler sets Content-Type: text/html; charset=utf-8 and X-Content-Type-Options: nosniff itself; html/template does not touch headers.

  • The list is sorted before it reaches the template, because MemStore.All iterates a map. Sorting belongs in Go code, not in the template.

  • The route patterns are the Go 1.22 ServeMux patterns: GET /{$} matches only the root, GET /books/{isbn} captures a path value that the handler validates with catalog.ParseISBN before 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 name Bo "B" Li are 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, "&lt;script&gt;alert(1)&lt;/script&gt;") {
			t.Errorf("%s: escaped title missing", target)
		}
	}
}
=== RUN   TestPagesEscapeTitles
--- PASS: TestPagesEscapeTitles (0.00s)
PASS
ok  	example.com/shelf/ex/e08	0.096s

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/template is 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. The text/template documentation 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

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"})
}
// 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}}}

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 with text/template plus 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, .Release and .Chart as 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 plain text/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

Things the example makes concrete:

  • include is 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 calls ExecuteTemplate and returns the text; Helm provides its include in the same spirit.

  • nindent 4 writes a newline and indents every line; YAML is whitespace-sensitive, and most Helm template bugs are an indentation or trim-marker slip.

  • required and default look at truthiness: a replicaCount of 0 is empty, so default 1 replaced 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, before default can supply a fallback; with an empty-but-present key (repository: ""), required produced its own message. Helm’s required and defaults work on keys that exist in values.yaml, which is why charts declare every value there.

  • kubectl runs the template with the decoded JSON, a map[string]any tree, so .items and .status.phase are map keys. A missing key is silent by default, which is the same trade-off as missingkey=default; the flag --allow-missing-template-keys=false of kubectl get turns 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 Parse per request, which repeats the escaping analysis and allocates the parse tree every time.

  • Fail early. Use template.Must or return the error from loadPages before 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 range with an if and a few formatting functions is fine; if a template needs a FuncMap with 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 with ParseFS globs such as templates/*.gohtml and templates/partials/*.gohtml.

  • Use html/template for everything that ends in a browser, including e-mail HTML bodies; use text/template for 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 kubectl use. This page does not cover third-party modules.

Common pitfalls

Pitfall What happens and what to do

Using text/template for HTML

No escaping; the page is open to XSS. Import html/template

Converting user input to template.HTML or template.URL

Disables escaping for that value; the conversion is where the vulnerability is

Forgetting that Funcs precedes Parse

function "x" not defined at parse time

Executing straight into the ResponseWriter

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

is an unexported field of struct type; export the field

A zero struct or time.Time in if

Always true; test a method such as IsZero

A range with one variable

The variable is the element (not the index, as in Go code)

Dot changed inside range or with

.Title now means the element’s field; use $.Title for the root

eq between an int and a string, or int and float32

incompatible types for comparison; convert in Go code

Relying on <no value>

Hidden typo in a map key; use Option("missingkey=error") in configuration templates

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 ParseFiles/ParseGlob/ParseFS; name them uniquely

Cloning or parsing an html/template set after it has executed

cannot Parse after Execute / cannot Clone ... after it has executed

Putting an action inside an HTML construct that changes context across a branch

ErrBranchEnd/ErrAmbigContext; keep tags complete inside each 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

{{break}} and {{continue}} in range; and and or stop evaluating arguments as soon as the result is known

Go 1.21

An action inside a JavaScript template literal is an error in html/template (ErrJSTemplate), backported to 1.19.8 and 1.20.3

Go 1.22 / 1.23

JavaScript template literals may again contain actions, and jstmpllitinterp has no effect (the release notes list this under Go 1.22, godebug.md under Go 1.23)

Go 1.23

{{else with}} chains in text/template and html/template

Go 1.24

range over integers and over iter.Seq/iter.Seq2 functions in templates

Go 1.25

sync.WaitGroup.Go, used above; the <meta> URL escaping fix is backported to 1.25.8

Go 1.26

errors.AsType (used above to unwrap template.ExecError); the <meta> fix is backported to 1.26.1

Go 1.27

html/template escapes URLs in <meta http-equiv="refresh" content="...; url=..."> (GODEBUG htmlmetacontenturlescape)