Introduction #
In the landscape of modern software development in 2025, efficiency is paramount. While Go (Golang) is celebrated for its simplicity and readability, that philosophy often comes with a trade-off: boilerplate. Whether it’s implementing String() methods for enums, creating mock interfaces for testing, or mapping database rows to structs, writing repetitive code is tedious and error-prone.
Enter Code Generation.
Unlike languages that rely heavily on runtime reflection (looking at you, Java and Python) or complex macros (Rust), Go takes a pragmatic approach. It encourages generating static Go source code before compilation. This technique offers the best of both worlds: the expressiveness of metaprogramming with the performance and type safety of static typing.
In this quick guide, we will move beyond the basics. We’ll look at how to leverage the standard stringer tool and, more importantly, how to build your own lightweight code generator using Go’s text/template engine.
Prerequisites #
To follow along, ensure you have the following setup:
- Go Version: Go 1.23 or higher (we are assuming a standard 2025 environment).
- IDE: VS Code (with Go extension) or JetBrains GoLand.
- Environment: A clean directory for this project.
Initialize your project:
mkdir go-codegen-demo
cd go-codegen-demo
go mod init github.com/yourusername/go-codegen-demoUnderstanding go:generate
#
The heart of Go’s code generation is the //go:generate directive. It is not a keyword; it is a specially formatted comment that the go generate command scans for.
When you run go generate ./..., Go scans your source files for these comments and executes the commands specified within them. It acts as a build-agnostic task runner specifically for code creation.
The Workflow #
Here is how the code generation pipeline typically looks in a professional Go project:
Tool 1: The Classic stringer
#
Let’s start with a ubiquitous problem: Enums. Go doesn’t have a native enum type; we use const blocks. However, printing these constants usually results in an integer, which makes logs hard to read.
Instead of writing a massive switch statement manually, we use stringer.
Step 1: Install the Tool #
go install golang.org/x/tools/cmd/stringer@latestStep 2: Create the Enum #
Create a file named payment.go. Note the comment at the top.
package main
import "fmt"
//go:generate stringer -type=PaymentStatus
type PaymentStatus int
const (
Pending PaymentStatus = iota
Authorized
Captured
Refunded
Failed
)
func main() {
status := Captured
// Without stringer, this prints "2"
// With stringer, this prints "Captured"
fmt.Printf("Current Status: %s\n", status)
}Step 3: Run Generation #
Execute the following in your terminal:
go generate ./...You will see a new file generated: paymentstatus_string.go. It contains an optimized approach (often using index tables) to map the integer to the string name.
Tool 2: Building a Custom Generator #
While tools like stringer, jsonenums, or sqlc are fantastic, senior developers often face unique requirements. Perhaps you need to generate Reset() methods for a pool of objects, or specific validation logic based on struct tags.
Let’s build a simple generator that creates a Constructor function for a struct automatically.
Step 1: The Template Generator (gen/main.go)
#
Create a folder named gen and a file gen/main.go. This program will accept a type name and generate code for it.
// gen/main.go
package main
import (
"flag"
"html/template"
"log"
"os"
"strings"
)
// Config holds data for the template
type Config struct {
Package string
Type string
}
// simpleTemplate defines how our constructor looks
const simpleTemplate = `// Code generated by go-codegen-demo; DO NOT EDIT.
package {{.Package}}
// New{{.Type}} creates a new instance of {{.Type}}
func New{{.Type}}() *{{.Type}} {
return &{{.Type}}{
// Default initialization logic could go here
}
}
`
func main() {
typeName := flag.String("type", "", "The type name to generate a constructor for")
pkgName := flag.String("pkg", "main", "The package name")
flag.Parse()
if *typeName == "" {
log.Fatal("type argument is required")
}
// Prepare output filename: type_constructor.go
filename := strings.ToLower(*typeName) + "_constructor.go"
f, err := os.Create(filename)
if err != nil {
log.Fatal(err)
}
defer f.Close()
// Execute template
tmpl := template.Must(template.New("constructor").Parse(simpleTemplate))
data := Config{
Package: *pkgName,
Type: *typeName,
}
if err := tmpl.Execute(f, data); err != nil {
log.Fatal(err)
}
log.Printf("Generated %s", filename)
}Step 2: Using the Custom Generator #
Now, let’s use this tool in our project. Go back to the root directory.
We need to build our tool first so go generate can call it. A common pattern is compiling it on the fly or ensuring it’s in the path. For this demo, we will run it directly using go run.
Create models.go:
package main
// We tell go generate to run the main.go inside the gen folder
// We pass the type "User" and package "main"
//go:generate go run ./gen/main.go -type=User -pkg=main
type User struct {
ID int
Username string
Email string
}Step 3: Generate and Verify #
Run the generation command again:
go generate ./...You should see output indicating Generated user_constructor.go. If you open that file, you will see your type-safe constructor ready to be used.
Comparison: Code Gen vs. Reflection #
Why go through the trouble of writing generators when Go supports Reflection (reflect package)? This is a common question during code reviews.
Here is a breakdown of why Code Generation usually wins for production systems in 2025:
| Feature | Code Generation | Runtime Reflection |
|---|---|---|
| Performance | High. Code is compiled as standard Go code. No runtime overhead. | Low. Reflection is significantly slower and prevents compiler optimizations (inlining). |
| Type Safety | Checked at Compile Time. If types mismatch, the build fails. | Checked at Runtime. Type errors cause panics in production. |
| Debuggability | Easy. You can step through generated code in your debugger. | Hard. Stepping into reflect library code is confusing and complex. |
| IDE Support | Excellent. Autocomplete works perfectly on generated structs/methods. | Poor. IDEs often cannot predict types manipulated via reflection. |
| Start-up Time | Fast. | Slower. Reflection often requires heavy initialization logic. |
Best Practices and Pitfalls #
As with any powerful tool, go generate can be misused. Here are tips to keep your project clean.
1. Commit Generated Code #
There is a debate on whether to commit generated files to Git. In the Go community, the consensus is YES.
- Why? It allows others to
go getand build your project without needing your specific generator tools installed. - Exception: If the generated code is massive (megabytes) or platform-specific in a way that breaks cross-compilation.
2. Don’t Edit Generated Files #
Generated files should be treated as read-only artifacts. Always add a header comment:
// Code generated by tool; DO NOT EDIT.
If you edit them manually, your changes will be wiped out the next time you run go generate.
3. Keep Generators Deterministic #
Running go generate twice without changing the source should result in the exact same output file. Avoid putting timestamps in the generated file headers, as this creates unnecessary diffs in version control.
4. Use stringer, mockgen, and sqlc
#
Don’t reinvent the wheel.
- Use
stringerfor enums. - Use
uber-go/mock(formerly gomock) for testing mocks. - Use
sqlcfor generating type-safe database code from SQL queries.
Conclusion #
Code generation is a superpower in the Go ecosystem. It allows us to maintain the language’s simplicity while automating the repetitive parts of software engineering. By mastering //go:generate and understanding how to construct simple AST or template-based generators, you elevate yourself from a user of the language to a tool builder.
In 2025, as systems become more complex, the ability to generate type-safe, performant boilerplate is what distinguishes a senior Go engineer from the rest.
Further Reading #
Happy coding, and may your boilerplate be forever automated!