The gap between memory and markup
You have a user profile in memory. The name is Alice, the age is 30, and the account status is active. You also have an HTML file with placeholders. String concatenation feels fragile. Regex replacement breaks the moment the markup changes. Go solves this with a dedicated templating engine that treats data substitution as a first-class operation. The engine reads your Go values, walks through the template syntax, and writes the final output to any io.Writer.
How the dot works
Think of a template as a form with labeled blanks. The {{.}} syntax is the cursor. It points to whatever data you hand to the engine. When you pass a struct, the cursor sits at the root of that struct. {{.Name}} tells the engine to look inside the current object, find the Name field, and print it. The engine does not guess. It follows strict rules about visibility, type safety, and output escaping. If a field is not exported, the template cannot see it. If a type does not match what the template expects, execution stops with a clear error.
The dot shifts context as you move through the template. Inside a range loop, the dot points to the current iteration item. Inside a with block, the dot points to the evaluated expression. When the block ends, the dot snaps back to its previous position. This stack-based context system keeps nested data clean without requiring global variables or messy scoping rules.
Minimal example
Here is the simplest way to merge a struct with a template string.
package main
import (
"html/template"
"os"
)
// Profile holds the data we want to render.
type Profile struct {
Name string
Age int
}
func main() {
// Parse the template string once. html/template auto-escapes output.
tmpl := template.Must(template.New("profile").Parse("Hello {{.Name}}, you are {{.Age}}."))
// Create the data struct. Fields must be capitalized to be visible.
data := Profile{Name: "Alice", Age: 30}
// Execute writes the rendered output to os.Stdout.
err := tmpl.Execute(os.Stdout, data)
if err != nil {
// Handle template execution errors gracefully.
panic(err)
}
}
The output prints Hello Alice, you are 30. to the terminal. The template.Must call panics if the template syntax is malformed. That is the standard Go convention for startup parsing. You want to fail fast when the template is broken, not serve half-rendered pages to users.
What happens under the hood
The Parse call builds an internal tree of nodes. Each {{...}} block becomes a node that knows how to evaluate its contents. When Execute runs, it walks that tree from top to bottom. It resolves {{.Name}} by calling the Name() getter on the passed struct. It converts the integer 30 to a string using default formatting. Finally, it streams the result to the provided writer.
The html/template package wraps every substituted value in an escaping function. If Name contained <script>, the output becomes <script>. This prevents cross-site scripting without manual intervention. The engine tracks the context of each insertion. It knows whether the value is going into HTML text, an attribute, a URL, or a JavaScript block. It applies the correct escaping strategy automatically.
Templates can only read exported fields. Go's visibility rules apply directly to the template engine. If you define name string with a lowercase letter, the parser silently ignores it during execution. The compiler will not catch this mistake. You will get a runtime error that says template: profile:1:2: executing "profile" at <.name>: can't evaluate field name in type main.Profile. Always capitalize the first letter of any field you intend to render.
Keep your template parsing at startup. Parsing is expensive. Execution is cheap. The engine compiles the template into an optimized bytecode-like representation. Re-parsing on every request wastes CPU cycles and memory. Parse once, execute many times.
Realistic HTTP handler
In a real application, you parse templates at startup and execute them per request. Here is how a standard HTTP handler looks.
package main
import (
"html/template"
"net/http"
"os"
)
// PageData groups the values needed for the response.
type PageData struct {
Title string
Items []string
Count int
}
// Global template instance parsed once during startup.
var pageTmpl = template.Must(template.New("page").Parse(`
<!DOCTYPE html>
<html>
<head><title>{{.Title}}</title></head>
<body>
<h1>{{.Title}}</h1>
<ul>
{{range .Items}}
<li>{{.}}</li>
{{end}}
</ul>
<p>Total: {{.Count}}</p>
</body>
</html>
`))
// handlePage renders the template and writes to the HTTP response.
func handlePage(w http.ResponseWriter, r *http.Request) {
// Prepare fresh data for each request.
data := PageData{
Title: "Dashboard",
Items: []string{"Reports", "Settings", "Users"},
Count: len([]string{"Reports", "Settings", "Users"}),
}
// Set content type before writing the body.
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// Execute streams directly into the HTTP response writer.
if err := pageTmpl.Execute(w, data); err != nil {
http.Error(w, "template error", http.StatusInternalServerError)
return
}
}
func main() {
http.HandleFunc("/", handlePage)
http.ListenAndServe(":8080", nil)
}
The range action iterates over the slice. Inside the loop, the dot shifts to point at each individual string. That is why {{.}} prints the item itself. When the loop ends, the dot returns to the original PageData struct. The Execute method writes directly to the http.ResponseWriter. The response streams to the client as soon as the first bytes are ready. You do not need to buffer the entire HTML string in memory.
Common traps and compiler feedback
Templates fail quietly if you feed them the wrong shape. Passing a map instead of a struct works, but you lose compile-time safety. The engine resolves map keys by string lookup. If you misspell a key, you get a runtime error like template: page:5:2: executing "page" at <.MisspelledKey>: map has no entry for key "MisspelledKey". Structs catch these mistakes earlier because the Go compiler rejects undefined fields during the data preparation phase.
Another common trap is mixing text/template and html/template. The text package skips escaping. Use it for emails, configuration files, or CLI output. Use the html package for anything that reaches a browser. The compiler does not stop you from using the wrong package. You just have to pick the right one at import time.
Forgetting to handle the error from Execute is a silent failure. The HTTP response will be empty or truncated. Always check the error and return a proper status code. The if err != nil { return err } pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Never swallow template errors.
Custom template functions require explicit registration. You cannot call strings.ToUpper directly inside a template. You must pass a template.FuncMap to template.New().Funcs(). The engine only exposes a small set of built-in actions like if, range, with, and eq. If you need date formatting or URL encoding, register the function yourself. The compiler will reject unknown function calls with a message like template: page:3:2: executing "page" at <formatDate>: function "formatDate" not defined.
Choosing your data shape
Use a struct when you need compile-time safety and predictable field access. Use a map when the data shape changes dynamically at runtime and you cannot define a fixed type. Use a slice when you need to render a repeated list of items with the range action. Use a primitive value when the template only needs a single string or number. Use custom template functions when you need to transform data during rendering, like formatting dates or truncating strings. Use ExecuteTemplate when you have multiple named templates in one file and need to render a specific one. Use template.HTML when you intentionally want to bypass escaping for trusted content.
Keep your data flat. Nested maps and deeply nested structs make templates hard to debug. Flatten your data before passing it to the engine. Let Go handle the composition. Let the template handle the presentation.