The boilerplate trap
You are building a service with twenty endpoints. Each endpoint needs a handler, a request struct, a response struct, and a validation function. You copy-paste the first one, change the names, and realize you made a typo in the third one. You fix it, then notice the seventh one has the same typo. You fix that, then realize the error handling logic drifted in two places.
This is the moment you write a code generator. A code generator is a Go program that writes Go code. It turns repetition into a single source of truth. You define the pattern once, feed it data, and get consistent files every time. The compiler treats the output exactly like code you wrote by hand.
What a code generator actually is
A code generator is just a program that outputs text files. In Go, the ecosystem expects generators to integrate with the go generate tool. You write a comment in your source file that tells go generate which command to run. The command executes your generator, which writes new .go files. The compiler then compiles those files alongside your hand-written code.
The generator runs at development time. It does not run when your application starts. It produces artifacts that become part of your binary. Think of it as a stamp. You design the stamp once. You press it onto paper as many times as you need. The stamp is your generator program. The paper is your project directory. The impression is the generated .go file.
Generators are cheap to write. They are Go programs. You can use text/template for structure, os for file I/O, and any library you like. The only constraint is that the output must be valid Go code.
Minimal example: a struct generator
Here is the simplest generator: a program that takes a struct name and outputs a file with a String method. It uses text/template to inject the name into a fixed template.
package main
import (
"os"
"text/template"
)
// GenString creates a file with a String method for the given struct.
func GenString(name string) error {
// template defines the output structure with a placeholder for the struct name
tmpl := `package main
// String returns the name of the {{.Name}}.
func (s {{.Name}}) String() string {
return "{{.Name}}"
}
`
// parse the template text into an executable template object
t, err := template.New("string").Parse(tmpl)
if err != nil {
return err
}
// create the output file with standard permissions
f, err := os.Create(name + "_string.go")
if err != nil {
return err
}
defer f.Close()
// execute the template, passing the struct name as data
return t.Execute(f, map[string]string{"Name": name})
}
func main() {
if len(os.Args) != 2 {
os.Exit(1)
}
if err := GenString(os.Args[1]); err != nil {
panic(err)
}
}
You invoke this generator via a directive. Add //go:generate go run ./gen -name User to your file. Run go generate ./.... The tool scans for directives, runs the command, and user_string.go appears. The compiler sees the new file.
If you forget to run the generator, the compiler rejects the program with undefined: User.String. The compiler has no idea a generator exists. It only sees files on disk.
How go generate works
The go generate command is a tool that scans Go source files for comments matching the pattern //go:generate command args. When it finds one, it executes the command in the directory containing the file.
The directive must be on its own line. It cannot share a line with other code. The command runs in the directory of the file containing the directive, not the directory where you invoked go generate. This matters when your project has nested packages.
Convention aside: go generate is not part of the build pipeline. go build does not run generators. You must run go generate explicitly, or add it to your CI script. This is a deliberate design choice. Go keeps the build deterministic and fast. Generation is a development step. If you expect go build to trigger generation, you will get undefined errors. Run the generator first.
Realistic example: generating handlers
Real generators often produce multiple files or handle complex logic. Here is a generator that reads a list of resources and produces handler files. It uses slices.Sort to ensure deterministic output and writes a standard header comment.
package main
import (
"fmt"
"os"
"slices"
"text/template"
)
// HandlerTemplate defines the structure of a generated handler file.
const HandlerTemplate = `package handlers
// Code generated by gen/main.go; DO NOT EDIT.
// {{.Resource}}Handler handles requests for {{.Resource}}.
type {{.Resource}}Handler struct{}
// New{{.Resource}}Handler returns a new handler instance.
func New{{.Resource}}Handler() *{{.Resource}}Handler {
return &{{.Resource}}Handler{}
}
`
// GenHandlers generates handler files for a list of resources.
func GenHandlers(resources []string) error {
// sort resources to ensure deterministic output order
slices.Sort(resources)
// parse the template once for reuse across files
t, err := template.New("handler").Parse(HandlerTemplate)
if err != nil {
return fmt.Errorf("parse template: %w", err)
}
for _, res := range resources {
// derive filename from resource name, lowercased
filename := fmt.Sprintf("%s_handler.go", res)
// create file with standard permissions
f, err := os.Create(filename)
if err != nil {
return fmt.Errorf("create %s: %w", filename, err)
}
// execute template for this resource
if err := t.Execute(f, map[string]string{"Resource": res}); err != nil {
f.Close()
return fmt.Errorf("execute %s: %w", filename, err)
}
// close file to flush data to disk
if err := f.Close(); err != nil {
return fmt.Errorf("close %s: %w", filename, err)
}
}
return nil
}
func main() {
resources := []string{"User", "Post", "Comment"}
if err := GenHandlers(resources); err != nil {
panic(err)
}
}
The generated files start with // Code generated by gen/main.go; DO NOT EDIT. This is the standard convention. It tells humans and tools that the file is managed. Linters often skip these files. gofmt still formats them, but the header signals intent. If you edit a generated file by hand, your changes will be overwritten the next time you run the generator.
Convention aside: Generated files should be idempotent. Running the generator twice must produce the same result. If your generator writes a file that already exists, it should overwrite it. If the content is identical, the file modification time may change, but the bytes must match. This allows tools to detect when regeneration is necessary.
Determinism and pitfalls
Generators must be deterministic. If your generator iterates a map, the order changes between runs. The output changes. Your CI build fails randomly because the diff is non-zero. Sort your keys. Use slices.Sort or maps.Keys with sorting. Determinism is the law. Sort your inputs or your CI will break.
Another pitfall is the go:generate directive path. The command runs in the directory of the file containing the directive. If you use relative paths in your generator, they resolve from that directory, not from your working directory. This catches newcomers when they move files between packages.
Error handling in generators should be strict. If the generator fails, it should exit with a non-zero status. The go generate tool propagates this failure. If your generator panics, go generate stops. Use log.Fatal or os.Exit for fatal errors. Do not swallow errors silently.
The compiler does not validate generated code until you run go build. If your generator produces invalid syntax, you will see errors like expected '}', found 'EOF' or undefined: MissingType. These errors point to the generated file, not the generator. Debug the generator by running it manually and inspecting the output.
Convention aside: gofmt is mandatory. Your generator should output formatted code. You can pipe the output through go/format in your generator, or run gofmt -w on the generated files afterward. Most teams run gofmt as a post-generation step to keep the repo clean.
Testing your generator
Generators are code. You should test them. The standard pattern is golden file testing. Run the generator, capture the output, and compare it to a stored expected file. If the output changes, update the golden file intentionally.
You can use os.CreateTemp to write to a temporary directory, read the files, and compare bytes. This avoids polluting your project with test artifacts. Tools like gotest.tools/v3/golden simplify this pattern.
Testing generators catches regressions. If you change the template and break a field name, the test fails immediately. You do not need to run go build to find the bug.
Decision matrix
Use go generate when you have repetitive boilerplate that depends on project structure. Use text/template when the output has variable parts but a fixed structure. Use go:embed when you need to include static assets in the binary, not generate code. Use the go/ast package when you need to parse and transform existing Go code programmatically. Use manual copy-paste when the code is unique and unlikely to change.
Generators are scripts. Treat them as code, not magic.