How to use text template package

Import text/template, parse a template string with placeholders, and execute it with a data map to generate dynamic text.

The problem with string concatenation

You are building a CLI tool that generates YAML configuration files. Or maybe you are writing a simple HTTP handler that returns plain text status reports. You start with a few string literals and some + operators. Then you add conditional logic. Then you add loops. The code quickly turns into a tangled mess of nested quotes, missing spaces, and hardcoded formatting rules. Changing the output layout means hunting through business logic. Changing the business logic means breaking the output layout.

Go solves this by treating text generation as a separate concern. The text/template package gives you a lightweight, predictable engine that keeps your layout in a template string and your data in a Go value. You write the structure once, mark the dynamic parts with placeholders, and hand the engine a data object. The engine walks the structure, replaces placeholders with actual values, and writes the result to any io.Writer.

Separate the layout from the logic. Keep your handlers thin.

How the template engine works

The package operates in two distinct phases: parsing and execution. During parsing, the engine reads your template string and builds an internal tree of nodes. It does not evaluate anything yet. It just records where the static text ends and where the dynamic expressions begin. During execution, the engine walks that tree, evaluates expressions against your data, and streams the output.

The dot {{.}} is the current data context. It points to whatever value you passed to Execute. As you use actions like range or with, the dot shifts to point at the current iteration or the new value. When the action ends, the dot returns to its previous position. This scoping rule is strict and predictable.

The engine also supports pipelines. You can chain functions together using the pipe symbol |. The output of one function becomes the input of the next. This keeps expressions readable and avoids deeply nested function calls.

Templates only see exported fields. If you pass a struct with lowercase field names, the engine treats them as invisible. This is a deliberate design choice that matches Go's visibility rules.

Parse once. Execute many times. Trust the dot.

The minimal working example

Here is the simplest way to parse a string template and run it against a struct.

package main

import (
	"os"
	"text/template"
)

// User holds the data we want to inject into the template.
type User struct {
	Name string
	Role string
}

func main() {
	// Create a new template container named "greeting".
	tmpl, err := template.New("greeting").Parse("Hello, {{.Name}}! You are a {{.Role}}.")
	if err != nil {
		// Parsing errors mean a syntax mistake in the template string.
		panic(err)
	}

	// Prepare the data object. Only exported fields are visible.
	data := User{Name: "Alice", Role: "developer"}

	// Execute writes the rendered output to os.Stdout.
	if err := tmpl.Execute(os.Stdout, data); err != nil {
		panic(err)
	}
}

The template.New call allocates a container. The Parse method reads the string and builds the node tree. The Execute method walks the tree, evaluates {{.Name}} and {{.Role}} against the User struct, and streams the result to standard output. The program prints Hello, Alice! You are a developer.

Keep the template string simple. Let the engine handle the heavy lifting.

Walking through execution

When Execute runs, the engine starts at the root of the parsed tree. It encounters static text and writes it directly to the io.Writer. When it hits an action like {{.Name}}, it evaluates the expression against the current dot. It looks up the Name field on the data object, converts it to a string, and writes it.

If you use range, the engine iterates over a slice, map, or array. For each iteration, it temporarily replaces the dot with the current element. Inside the range block, {{.}} refers to the element, not the original data. When the block closes, the dot reverts. This scoping behavior prevents accidental data leakage between loops.

The engine also handles missing fields gracefully during execution. If your template references {{.Missing}} and the data object does not have that field, the engine does not panic. It writes an empty string and continues. This behavior is intentional. It prevents a single missing field from crashing your entire output pipeline.

If you need strict validation, you must check for missing fields yourself before calling Execute, or use a custom template function that panics on nil values.

The engine streams output. It does not build a giant string in memory.

A realistic configuration generator

Real projects rarely use single-line templates. You usually parse a multi-line string or a file, handle errors properly, and pass structured data. Here is a pattern that matches production code.

package main

import (
	"fmt"
	"os"
	"text/template"
)

// Config holds the values we want to inject.
type Config struct {
	Host     string
	Port     int
	Features []string
}

func generateConfig(cfg Config) error {
	// Multi-line template with range and conditional logic.
	const tmplStr = `server:
  host: {{.Host}}
  port: {{.Port}}
features:
{{range .Features}}
  - {{.}}
{{end}}
`
	// Parse the template string. The name "config" helps with error messages.
	tmpl, err := template.New("config").Parse(tmplStr)
	if err != nil {
		return fmt.Errorf("template parse failed: %w", err)
	}

	// Execute writes to os.Stdout. Replace with a file or buffer in production.
	if err := tmpl.Execute(os.Stdout, cfg); err != nil {
		return fmt.Errorf("template execution failed: %w", err)
	}

	return nil
}

func main() {
	cfg := Config{
		Host:     "localhost",
		Port:     8080,
		Features: []string{"logging", "metrics", "tracing"},
	}

	if err := generateConfig(cfg); err != nil {
		fmt.Fprintf(os.Stderr, "failed: %v\n", err)
		os.Exit(1)
	}
}

The range action iterates over the Features slice. Each iteration replaces the dot with a string from the slice. The template writes a YAML list item for each feature. The {{end}} action closes the block and restores the original dot context.

Notice the error handling pattern. The function returns errors instead of panicking. The caller decides how to handle them. This matches the standard Go convention of surfacing failures explicitly.

Wrap template errors with fmt.Errorf. Let the caller decide the exit strategy.

Pitfalls and runtime behavior

Templates are powerful, but they have strict rules that trip up newcomers. The first rule is visibility. The engine only sees exported fields. If you pass a struct with lowercase fields, the template sees an empty object. The compiler will not catch this. You will get a runtime warning or empty output.

The second rule is type safety. The engine expects specific types for specific actions. If you pass a string to range, the engine rejects it with range can't iterate over string. If you pass a non-boolean to if, the engine rejects it with if requires a boolean condition. These are runtime errors, not compile-time errors. The template package is a runtime library, not a compile-time type checker.

The third rule is escaping. The text/template package does not escape HTML entities. It outputs exactly what you give it. If you are generating HTML, use html/template instead. The HTML version automatically escapes dangerous characters to prevent injection attacks. Using the wrong package is a common security mistake.

There is also a convention for static templates. If your template string is hardcoded and never changes at runtime, wrap the parse call in template.Must. The function panics if parsing fails, which turns a silent nil pointer into a loud startup crash. This is the standard pattern for templates embedded in binaries.

// Static templates should fail fast at startup.
tmpl := template.Must(template.New("static").Parse("{{.Name}}"))

Do not use template.Must for user-provided or file-loaded templates. Those should return errors so you can handle them gracefully.

Static templates crash early. Dynamic templates return errors. Pick the right pattern.

When to reach for templates

You will encounter several ways to generate text in Go. Each tool fits a specific shape of problem. Match the tool to the requirement.

Use text/template when you need to generate plain text, configuration files, or CLI output with conditional logic and loops. Use html/template when you are rendering web pages or any markup that requires automatic escaping for security. Use fmt.Sprintf when you have a simple, one-off string with a fixed number of placeholders and no loops or conditionals. Use plain string concatenation when you are building a tiny, static string that never changes and performance matters more than readability. Use a dedicated code generator or AST library when you need to produce valid Go source code with complex indentation and type safety.

Pick the tool that matches the output format. Do not force a template engine into a job that needs a simple format string.

Where to go next