When static strings fall short
You are building a CLI tool that prints deployment status. The backend returns a slice of service records. Some are healthy, some are failing, and sometimes the slice is completely empty. You need the output to skip failed services, print a warning when nothing is returned, and format each healthy service on its own line. JavaScript lets you drop if and for loops directly into the markup. Go templates take a different path. They treat control flow as explicit directives, keeping the template engine predictable, safe, and easy to parse.
The dot and the directive
Go does not inject programming language keywords into its template syntax. Instead, it uses a small set of built-in actions wrapped in double curly braces. The two you will reach for constantly are if and range. They work exactly like you expect, but they operate on a single concept: the dot. The dot represents the current data context. When you pass a value to Execute, the dot becomes that value. Inside a range block, the dot temporarily becomes each element in the collection. Inside an if block, the dot stays the same unless you explicitly change it. This design prevents template authors from accidentally mutating state or running arbitrary code.
The template engine evaluates directives left to right. It does not compile to bytecode. It walks an abstract syntax tree at runtime, resolving each action against the current dot. This makes templates fast enough for high-throughput servers while keeping the implementation simple enough to audit.
Keep the dot in focus. Every template decision revolves around what the dot currently points to.
Minimal control flow
Here is the simplest way to see control flow in action. We pass a slice of strings to a template that checks for emptiness and iterates over the items.
package main
import (
"os"
"text/template"
)
func main() {
// Define the template string with if and range directives
tmpl := `{{if .}}
Status: Active
{{range .}}
- {{.}}
{{end}}
{{else}}
Status: Empty
{{end}}`
// Parse the template and handle parse errors immediately
t := template.Must(template.New("status").Parse(tmpl))
// Execute with a slice of strings as the data context
t.Execute(os.Stdout, []string{"alpha", "beta"})
}
The parser reads the template string and builds an abstract syntax tree. When Execute runs, it evaluates {{if .}}. The dot is the slice []string{"alpha", "beta"}. In Go templates, a non-empty slice evaluates to true. The engine prints "Status: Active" and moves into the {{range .}} block. The dot temporarily switches to "alpha", prints it, then switches to "beta", prints it, and finally restores the original dot when {{end}} closes the loop. If you passed an empty slice, the if condition would fail, the engine would skip straight to {{else}}, and you would see "Status: Empty".
The dot is your anchor. Every action inside a template resolves relative to it. When you write {{.Name}}, you are asking the current context for a field or method named Name. When you write {{range .Servers}}, you are telling the engine to iterate over the Servers field and temporarily replace the dot with each element. This scoping rule is strict. It keeps templates readable and prevents hidden side effects.
Track the dot at every step. If you lose it, the template breaks.
How the engine evaluates truth
Go templates use a specific truthiness model that differs from JavaScript or Python. The engine considers a value false only if it is the zero value for its type. A zero value means false for booleans, 0 for numbers, "" for strings, nil for pointers and interfaces, and an empty slice or map. Everything else is true. This means a slice with one element is true. A map with one key is true. A struct with all zero fields is still true because the struct itself is not nil.
This behavior matters when you filter data. If you pass a pointer to a struct that happens to be nil, {{if .}} will skip the block. If you pass a non-nil pointer to an empty struct, {{if .}} will run. The engine does not inspect the contents of the struct. It only checks the pointer itself.
You can also compare values directly. The syntax {{if eq .Status "active"}} works exactly like a standard equality check. The template engine supports eq, ne, lt, le, gt, and ge for numbers and strings. It does not support arbitrary expressions like {{if .A > 5 && .B < 10}}. You must chain conditions or handle complex logic in Go before passing the data to the template.
Truthiness is simple but strict. Test your zero values before writing the template.
Realistic rendering pattern
Real applications rarely pass flat slices. You usually pass structs, maps, or nested collections. Here is a practical pattern for rendering a configuration report. We use html/template instead of text/template because the output will eventually be sent to a browser. The html package automatically escapes dangerous characters, which is a convention you should follow whenever the destination is a web page.
package main
import (
"os"
"html/template"
)
// Server holds basic status information for a node
type Server struct {
Name string
Online bool
Port int
}
func main() {
// Template uses if to filter and range to iterate
tmpl := `{{range .}}
{{if .Online}}
[OK] {{.Name}} listening on {{.Port}}
{{else}}
[DOWN] {{.Name}} unreachable
{{end}}
{{end}}`
// Must panics if the template contains syntax errors
t := template.Must(template.New("report").Parse(tmpl))
// Pass a slice of structs as the root context
data := []Server{
{Name: "web-1", Online: true, Port: 8080},
{Name: "db-1", Online: false, Port: 5432},
}
t.Execute(os.Stdout, data)
}
The template walks the slice. The dot becomes each Server struct. The if directive checks the Online field. If true, it prints the healthy status. If false, it prints the down status. The {{end}} closes the if, and the outer {{end}} closes the range. The engine restores the dot to the original slice after the loop finishes.
You will often need to access the original data from inside a nested block. The $ variable always points to the root context passed to Execute. If you need to reference the parent slice while iterating, write {{range $items := .}} to capture it, or use $ directly. This avoids shadowing and keeps nested templates readable.
The community convention for template initialization is template.Must. It wraps Parse and panics if the template is malformed. This is intentional. A broken template means the application cannot render correctly, so failing fast at startup is safer than failing silently in production. For dynamically loaded templates, drop Must and handle the error explicitly with if err != nil { return err }.
Templates are data-driven, not code-driven. Keep the logic in Go and the formatting in the template.
Pitfalls and parser boundaries
Template errors surface at parse time, not runtime. The engine is strict about syntax. Forgetting a closing {{end}} triggers a parse error like template: report:3: unexpected "}" in operand. Mixing up the dot inside nested blocks is the most common logical mistake. If you nest a range inside another range, the inner dot shadows the outer one. You cannot access the outer data without using $ to capture the root context.
Type mismatches also break templates silently if you are not careful. If you pass a map with string keys but try to access a field that does not exist, the template engine returns an empty string instead of panicking. This is by design. It prevents missing fields from crashing your server. The compiler will reject your program with template: report:5: executing "report" at <.Missing>: can't evaluate field Missing in type main.Server if you try to access a struct field that the type system knows does not exist. Always verify your data shape matches the template expectations.
Another trap is ignoring the difference between text/template and html/template. They share the exact same syntax. The difference is the output sanitizer. If you render user input with text/template, you will inject raw HTML and open yourself to cross-site scripting. The community convention is clear: use html/template for anything that touches a browser, and use text/template for emails, CLI output, or configuration files.
You can extend templates with custom functions, but you must register them before parsing. The engine does not allow arbitrary Go function calls. You pass a template.FuncMap to template.New("name").Funcs(funcMap). The functions must return a single value or a value plus an error. If a custom function returns an error, the template execution stops immediately. This keeps the boundary between template and application code clean.
Never trust template input. Escape early, validate late, and keep functions pure.
Choosing the right tool
Use if when you need to branch based on truthiness, presence, or equality. Use range when you need to iterate over slices, maps, or channels. Use with when you want to change the dot to a specific field without iterating. Use $ when you need to reference the original root data inside a nested block. Use html/template when the output goes to a browser. Use text/template when the output goes to a terminal, a file, or a non-HTML parser. Use template.Must during initialization when a broken template means the application cannot start. Use template.New().Parse() with explicit error handling when templates are loaded dynamically at runtime. Use custom FuncMap functions when you need formatting logic that belongs in Go, not in the template. Use pipeline syntax {{. | upper}} when you want to chain simple transformations without writing a full function.
Pick the directive that matches the data shape. Force logic into templates and you will regret it.