The text/template package is Go's built-in engine for turning a template plus some data into a finished string. Order confirmation emails, shipping notifications, plain-text invoices, daily inventory reports, and any other place where the shape of the output is fixed but the values change is the job this package was built for. This chapter walks through how templates work from the smallest action to multi-file template trees, and where the performance cliffs are.
Producing dynamic text in Go without a template engine usually ends up looking like a long series of fmt.Fprintf calls or string concatenations. That works for one short line, but it falls apart fast. A shipping notification has fixed wording, a few customer-specific values, an address block that may or may not include a second line, and a list of items whose length is unknown until runtime. Encoding all of that in code mixes two concerns: the wording of the message and the logic that fills it in. Changing a comma in the greeting becomes a code change. So does adding a "P.S." line for premium customers.
Templates fix this by separating the two. The template file holds the wording. The Go code provides the data. The template engine glues them together. Anyone who can read English can edit the template; only the Go programmer touches the code.
The shape never changes. You parse a template once to build a parse tree, then call Execute with whatever data you want each time. Parsing is the expensive step. Executing the parsed template is cheap and reusable.
Here's the smallest useful program. A welcome message for a new customer.
template.New("welcome") creates an empty template with a name. Parse reads the template text and produces a tree. Execute walks that tree, replacing {{.Name}} with the value of the Name field on the data we passed in. The output goes to whatever io.Writer you hand it, so os.Stdout, a bytes.Buffer, an *os.File, or an HTTP response writer all work the same way.
Anything between {{ and }} is an action. The simplest action is {{.}}, which prints the current data value. By default the current value, called the dot, is whatever you passed to Execute. From there, {{.FieldName}} reads a struct field, {{.MapKey}} reads a map key, and {{.Method}} calls a method that takes no arguments and returns one or two values (the second being an optional error).
A few things worth pointing out. template.Must is a small helper that panics on a parse error, useful when the template is a constant string defined at startup. The action {{.FormattedTotal}} calls the method, not a field, because the engine sees that Order has no FormattedTotal field but does have a method with that name. Methods are looked up the same way as fields, and the engine doesn't need parentheses to call them.
For map access, the dot syntax also works, but only when the key is a valid Go identifier. Otherwise you need the index function.
A pipeline chains values through functions using |, the same way a Unix shell pipe does. The value to the left of the pipe becomes the last argument of the function to the right. This is how you format, transform, or otherwise reshape a value before it lands in the output.
The pipeline .Name | upper is equivalent to upper .Name. Both forms work. The pipe form reads better when you're chaining several transformations, because each step appears in the order it runs.
That reads .Email first, trims whitespace, then lowercases the result. The order matches the data flow. The function-call form would invert it (lower (trim .Email)), which is harder to read.
Pipelines can also chain across multiple values when a function takes more than one argument. The piped value is always the last argument, which is why the built-in printf is commonly used like {{.Price | printf "$%.2f"}}.
Templates have control structures. The {{if}} action checks whether the pipeline produces a truthy value (any non-zero, non-empty, non-nil value), and only emits the inner text if so.
The {{end}} closes the if. The block between {{if .Discount}} and {{end}} only renders when .Discount is non-zero. The same logic applies to .PremiumMember. If both fields were zero values, both lines would disappear entirely.
You can chain conditions with {{else if}} and {{else}}.
The {{with}} action is a close cousin of {{if}}. It also checks truthiness, but it rebinds the dot to the pipeline value for the block. This is handy when you have an optional sub-struct and want to drill into it without repeating the path.
Inside the {{with}} block, the dot is the Address, so {{.Street}} refers to Address.Street, not Order.Street. When .Shipping is nil, the entire block is skipped, so you don't need to nil-check before drilling in.
The {{range}} action loops over a slice, array, map, or channel. Inside the loop body, the dot is rebound to each element.
Inside the range, {{.Quantity}} reads from the current Item, not the outer Order. If you need to reach back to a field on the outer scope, assign it to a variable first or use {{$.FieldName}}, which always refers to the original data passed to Execute.
You can also capture the index and value explicitly with the := form, similar to a Go for loop.
That prints each item's position alongside its name. The $i and $item variables are scoped to the range block.
{{range}} also supports an {{else}} clause that runs when the collection is empty. This is the cleanest way to handle an empty cart, an empty wishlist, or no orders.
When .Items has at least one element, the loop body runs. When it's empty or nil, the else branch runs instead. There's no need to wrap the range in a separate {{if}}.
Ranging over a map is the same syntax, but the dot becomes the value, and the optional first variable becomes the key.
Cost: map ranges in templates iterate in a sorted order by key, which is different from for k, v := range m in Go code. The package sorts to keep template output deterministic. For very large maps that ordering is an extra O(n log n) cost per execute call.
Templates copy literal text exactly, including the spaces and newlines around actions. That's fine for free-form text, but it makes structured output (CSVs, JSON-ish records, anything where indentation matters) noisy fast. The trim markers {{- and -}} strip whitespace on the marked side.
Without the trim markers, you'd get blank lines between every entry because the newlines in the template would survive into the output. The {{- says "strip whitespace (including newlines) immediately before this action", and -}} says "strip whitespace after this action". Use them on the side facing the noise.
The trim markers must include the space: {{- .Name}} is correct, {{-.Name}} is a parse error. The Go authors made that deliberate so the markers stand out visually.
For anything beyond a one-liner, templates live in their own files. text/template has three helpers for loading them.
template.ParseFiles takes one or more filenames. Each file becomes a named template, the name being the base filename.
The result is one *Template with two named sub-templates. ExecuteTemplate picks which one to render. Execute without a name runs the first file parsed.
template.ParseGlob is the same idea but takes a shell glob pattern, useful when you have a directory of templates.
template.ParseFS reads from any fs.FS, which is what makes it work with the embed package. This is the pattern most production code uses, because it bundles the templates into the compiled binary so there are no files to ship and no filesystem reads at runtime.
The //go:embed directive ties a virtual filesystem to a package variable at build time. From the template's point of view, it doesn't matter whether the file came from disk or from embed.FS, the parsing API is identical.
Cost: parsing is the expensive step. A template that's reparsed inside an HTTP handler is hundreds of microseconds of avoidable work per request. Parse once at startup, store the *Template in a package variable, and call Execute on every request.
A single template file can contain multiple named templates by using the {{define}} action. This is how you break up a large output into reusable pieces.
To assemble them into a final output, call one template from another with {{template "name" .}}, passing the data the called template needs.
The . at the end of each call passes the current dot to the called template. Without it, the called template gets a nil dot and .ID would fail. You can also pass a sub-value, like {{template "address" .Shipping}}, which is how you keep child templates ignorant of the parent's shape.
Here's a complete program using define and template together.
The {{block "name" .}}...{{end}} action is a shorthand for "define and immediately invoke", and it's especially useful for layouts. Block defines a named template and renders it in place. Any later template loaded into the same set can override that block by redefining the name.
That renders the default greeting. If you load another file that says {{define "greeting"}}Hi there!{{end}}, the override replaces the default. This is the foundation for layout-style templates, where a base file defines blocks like "title", "body", and "footer", and child files override them.
The diagram shows what block enables. Both files contribute templates to the same set. When the base file is executed, its block references look up names in that set, finding either the base's own defaults or the child's overrides. This is composition without inheritance.
The built-in functions cover the basics, but real templates need custom logic: formatting prices, formatting dates, pluralizing words, computing line totals. text/template lets you register your own functions through template.FuncMap.
Three rules worth knowing about Funcs. First, you must call Funcs before Parse. The parser needs to know which names are valid functions; if it sees upper without that function registered, it raises a parse error. Second, function names follow Go identifier rules and can't shadow built-in actions like if or range. Third, functions must return one value, or one value plus an error. If they return an error, Execute stops and returns that error to the caller.
When divide returns a non-nil error during execution, the template aborts. This is how you propagate runtime errors from custom functions back to your Go code.
The package ships with a small set of built-in functions that handle the most common needs. They're available without any Funcs setup.
| Function | What it does | Example |
|---|---|---|
len | Length of slice, map, or string | {{len .Items}} |
index | Index into slice or map | {{index .Items 0}}, {{index .Stock "mouse"}} |
slice | Slice a slice or string | {{slice .Name 0 3}} |
print | Like fmt.Sprint | {{print "x=" .X}} |
printf | Like fmt.Sprintf | {{printf "$%.2f" .Total}} |
println | Like fmt.Sprintln | {{println .Msg}} |
not | Logical NOT | {{if not .Empty}}...{{end}} |
and | Logical AND (short-circuit) | {{if and .Verified .Active}}...{{end}} |
or | Logical OR (short-circuit) | {{if or .Premium .Trial}}...{{end}} |
eq | Equal | {{if eq .Status "shipped"}}...{{end}} |
ne | Not equal | {{if ne .Count 0}}...{{end}} |
lt, le, gt, ge | Less than, less or equal, etc. | {{if gt .Stock 0}}In stock{{end}} |
call | Call a function value | {{call .Formatter .Price}} |
html, js, urlquery | Escape strings for HTML/JS/URLs | rarely used in text/template |
A quick note on eq and friends. Unlike Go's ==, they're variadic on the right side. {{eq .Status "shipped" "delivered"}} is true when .Status equals either string. That makes status checks cleaner.
Mixing built-ins with custom funcs in a single pipeline is normal.
The actions and operators come together into a small but expressive grammar. This table summarises the shapes you'll see most often.
| Action | Meaning |
|---|---|
{{.}} | Current dot value |
{{.Field}} | Field on the current dot (struct field, map key, or method) |
{{.A.B.C}} | Chained field access |
{{$var}} | Variable from a := assignment |
{{$.Field}} | Root data, even inside a range or with |
{{$x := .Value}} | Assign to a variable, scoped to the enclosing block |
{{if PIPE}}...{{else}}...{{end}} | Conditional |
{{with PIPE}}...{{end}} | Rebind dot to PIPE if truthy |
{{range PIPE}}...{{else}}...{{end}} | Loop, with optional empty branch |
{{define "name"}}...{{end}} | Define a named sub-template |
{{template "name" PIPE}} | Invoke a named sub-template with PIPE as the dot |
{{block "name" PIPE}}...{{end}} | Define and invoke; overrideable |
{{- ACTION}} / {{ACTION -}} | Trim adjacent whitespace |
{{/* comment */}} | Comment, not emitted |
By default, when an action references a map key that doesn't exist, text/template substitutes the string <no value> and keeps going. That's permissive, and often what you want for forgiving display code, but it hides bugs. The Option method lets you change that behavior.
The three options for missingkey are:
| Option | Behavior on missing map key |
|---|---|
missingkey=default (the default) | Substitute <no value> and continue |
missingkey=zero | Substitute the zero value of the map's value type |
missingkey=error | Return an error from Execute |
missingkey=zero is the most useful for production. A missing string becomes "", a missing int becomes 0, a missing slice becomes nil. The output is silent rather than loud, which is what most user-facing text wants. missingkey=error is the right choice for internal pipelines where any missing value indicates a programming mistake.
Note that missingkey only applies to maps. Missing struct fields are always a parse-time error, because the engine knows the struct's shape ahead of time and can verify field names exist.
Beyond missing keys, the general error story is straightforward. Parse returns a non-nil error if the template syntax is bad. Execute returns a non-nil error if a function call fails, if an action references something that doesn't exist on a struct, or if an I/O write to the writer fails. Always check both.
Cost: Execute does its work through reflection. That makes it slower than hand-written fmt.Fprintf code by a healthy multiple. For a typical web app rendering pages or emails, the difference is invisible. For a tight inner loop building millions of records per second, generated code or a simpler strings.Builder will outperform templates.
The next chapter covers html/template, which has the same syntax but adds contextual auto-escaping: the engine knows whether a value is being inserted into HTML body text, an HTML attribute, a URL, or JavaScript, and escapes the value appropriately to prevent cross-site scripting. Use html/template for any output that ends up in a browser. Use text/template for plain text: emails, reports, CSV-ish files, config files, anything that isn't HTML.
A complete shipping notification, using most of the features above.
Three details stand out in the structure. The items and shipping sub-templates each focus on one piece of data. The top-level notification template orchestrates the order. {{with .Shipping}} inside the shipping sub-template gracefully handles the case where Shipping is nil (the block simply skips). The custom price function handles all currency formatting in one place. If the project moves to a different currency or a locale-aware format, that one function changes and the rest stays put.
Cost: parse this template once at package init, store the *Template in a package variable, and reuse it for every notification. Reparsing per request is hundreds of microseconds of waste.
10 quizzes