The missing inheritance feature
You come from Django, Jinja, or EJS, and you expect a clean {% extends "base.html" %} directive. You write it in Go, and the template engine quietly ignores it. Go does not have template inheritance. It does not have layout blocks. It has named definitions and explicit calls. That sounds limiting until you realize the engine is just a string interpolator with a strict safety net. You build layouts by plugging named pieces into a base shell.
How named blocks replace layouts
The html/template package treats templates as a flat namespace of named blocks. {{define "name"}} carves out a reusable chunk. {{template "name" .}} drops that chunk into another file. The dot (.) is the data context. It travels through every template call unless you explicitly change it. There is no parent-child hierarchy. There is only a collection of named strings that get stitched together at execution time.
Think of it like a theater stage. The base template is the set design: walls, lighting rigs, and a marked spot on the floor. The child template is the actor stepping onto that marked spot. The stage does not inherit from the actor. The actor simply occupies a predefined position. You control exactly where the data flows and which block gets rendered.
Minimal composition example
Here is the simplest way to stitch a header, a content slot, and a footer into one page. The base file defines the shell. The child file defines the slot. The Go program parses both and executes the shell.
// base.html
{{define "base"}}
<!DOCTYPE html>
<html>
<head><title>{{.Title}}</title></head>
<body>
<!-- The dot passes the full data map into the child block -->
{{template "content" .}}
</body>
</html>
{{end}}
// page.html
{{define "content"}}
<h1>{{.Heading}}</h1>
<p>{{.Body}}</p>
<!-- The dot remains available here because base passed it explicitly -->
{{end}}
// main.go
package main
import (
"html/template"
"os"
)
// main starts the composition and writes to stdout
func main() {
// ParseFiles merges every file into a single template namespace
tmpl := template.Must(template.ParseFiles("base.html", "page.html"))
data := map[string]string{
"Title": "Home",
"Heading": "Welcome",
"Body": "Hello World",
}
// ExecuteTemplate picks the entry point block by name
tmpl.ExecuteTemplate(os.Stdout, "base", data)
}
The engine does not guess which file is the layout. You tell it exactly where to start by passing "base" to ExecuteTemplate. The dot carries the map through every boundary.
What happens at runtime
When you call template.ParseFiles, Go reads each file, tokenizes the directives, and stores every {{define}} block in a single *template.Template struct. The struct acts like a dictionary. Keys are block names. Values are parsed AST nodes.
Calling ExecuteTemplate(w, "base", data) looks up the "base" key. It begins rendering from the top. When it hits {{template "content" .}}, it pauses, looks up the "content" key, injects the current dot, and resumes. The output streams directly to the writer you provided. No intermediate strings are built. No inheritance chain is resolved. Just a flat lookup and a sequential render.
If you pass a map, the dot becomes that map. If you pass a struct, the dot becomes that struct. Field access uses the exported name. {{.Title}} works for both. The engine does not care about the underlying type as long as the keys or fields exist.
Parse once. Render many times. The AST is immutable after parsing.
Realistic HTTP handler
Production code rarely prints to os.Stdout. It serves HTTP responses. You parse templates at startup, cache the compiled object, and reuse it across requests. Here is how that looks in a minimal server.
// server.go
package main
import (
"html/template"
"log"
"net/http"
)
// templates holds the compiled layout and page blocks
var templates *template.Template
// init runs once before the server starts
func init() {
// ParseFiles returns an error if any file is missing or malformed
var err error
templates, err = template.ParseFiles("base.html", "page.html")
if err != nil {
// Fatal is acceptable at startup when the program cannot function
log.Fatalf("template parse failed: %v", err)
}
}
// handleHome serves the composed page to the browser
func handleHome(w http.ResponseWriter, r *http.Request) {
data := map[string]string{
"Title": "Home",
"Heading": "Welcome",
"Body": "Hello World",
}
// ExecuteTemplate writes directly to the HTTP response writer
if err := templates.ExecuteTemplate(w, "base", data); err != nil {
// Log the error and return a 500 instead of panicking
log.Printf("render failed: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}
// main starts the HTTP listener on port 8080
func main() {
http.HandleFunc("/", handleHome)
log.Println("listening on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
The if err != nil pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. You never swallow template errors in production. You log them and return a safe fallback.
Convention aside: template.Must is fine for quick scripts, but init() with explicit error handling gives you control over startup failure. Most editors run gofmt on save, but gofmt does not touch .html files. The template engine handles auto-escaping automatically, so you do not need to manually sanitize strings.
Compile the AST once. Stream the output per request.
Common traps and runtime panics
The template engine is strict. It will not silently skip missing blocks. It will panic at runtime with template: "base" is undefined if you misspell a name or forget to parse a file. The error happens during ExecuteTemplate, not during ParseFiles, because the engine validates block existence at render time.
Forgetting the dot in {{template "content" .}} is the most common mistake. Without the dot, the child block receives an empty context. {{.Heading}} renders as nothing. The engine does not warn you. It just outputs blank space. Always pass the dot explicitly.
Mixing ParseFiles and ParseGlob on the same template object causes namespace collisions. If two files define the same block name, the second one overwrites the first. The compiler accepts this. The runtime uses the last parsed version. Keep block names unique across your project.
The engine also panics with template: "base" executed at ...: bad character in name if you use spaces or special characters in block names. Stick to alphanumeric names and underscores.
Template errors are runtime panics, not compile-time type checks. Write a small test that renders every route and asserts the output contains expected markers.
Test the render path. Catch the panic before it hits production.
When to compose manually versus switching tools
Use manual {{define}} and {{template}} composition when you need zero dependencies, strict auto-escaping, and full control over the render pipeline. Use a third-party layout library like skela or go-embed with custom parsing when your project requires nested partials, template inheritance syntax, or asset bundling without sacrificing the standard library. Use a frontend framework like React or Svelte when the UI requires client-side interactivity, state management, or component-driven development that server-side string interpolation cannot support.
Go templates are not a UI framework. They are a safe string composer. Treat them as plumbing, not as a feature set.