Go Advanced #7 Code Generation — go generate and stringer

6 min read

If #6 Profiling showed measurement tools — this post is about a case that’s often discovered through measurement. How to make hot-path reflect fast.

Go’s answer is — code generation. Instead of analyzing with reflect at runtime, generate the code ahead of time at compile time. The final post of the Advanced series.

The picture of go generate #

go generate — a tool that runs commands when it sees special comments in your source.

//go:generate directive
//go:generate stringer -type=Status
package main

type Status int

const (
	Pending Status = iota
	Active
	Closed
)
go generate ./...

This command — finds the comment above and runs stringer -type=Status. That tool generates a new file.

Result — auto-generated file #

The status_string.go produced by stringer:

auto-generated (summary)
// Code generated by "stringer -type=Status"; DO NOT EDIT.

package main

func (i Status) String() string {
	switch i {
	case Pending:
		return "Pending"
	case Active:
		return "Active"
	case Closed:
		return "Closed"
	}
	return "Status(...)"
}

Now Status — satisfies fmt.Stringer. fmt.Println(s) produces a human-readable string like “Active”.

Why is it better than reflect? #

You can do the same with reflect — resolving enum names dynamically — but:

Comparisonreflectcode gen
Speed~100x slowersame as ordinary code
Compile-time checknoneyes
Debugginghardeasy
Build complexitysimpleadds a generate step

When hot-path reflect can be solved with compile-time information — code generation is always better.

stringer — enum string representation #

install stringer
go install golang.org/x/tools/cmd/stringer@latest
multiple enums
//go:generate stringer -type=Status,Priority

type Status int
const (
	Pending Status = iota
	Active
)

type Priority int
const (
	Low Priority = iota
	High
)

Generated files: status_string.go, priority_string.go. Whenever an enum is added — one go generate regenerates them.

mockgen — interface mocking #

go.uber.org/mock’s mockgen — auto-generates a mock from an interface.

source interface
//go:generate mockgen -source=db.go -destination=mock_db.go -package=mocks

type DB interface {
	Get(id string) (User, error)
	Save(u User) error
}

With the generated mock — you can predefine behavior in tests.

using the mock
mockDB := mocks.NewMockDB(ctrl)
mockDB.EXPECT().Get("u1").Return(User{Name: "Dokyung Lee"}, nil)

svc := NewService(mockDB)
svc.Greet("u1")

You could build dynamic mocks with reflect — but generation is better for IDE autocomplete, compile-time type checking, and debugging.

Building your own — with text/template #

A common pattern.

generator.go
//go:build ignore

package main

import (
	"os"
	"text/template"
)

const tmpl = `// DO NOT EDIT.
package main

func {{.Name}}Greet() string {
	return "Hello, {{.Name}}!"
}
`

func main() {
	t := template.Must(template.New("g").Parse(tmpl))
	f, _ := os.Create("greet_gen.go")
	t.Execute(f, map[string]string{"Name": "World"})
}
actual code
//go:generate go run generator.go

The //go:build ignore tag — excludes it from normal builds and runs only via go run generator.go.

Pattern — accelerating serialization #

A common case in larger projects. encoding/json’s reflect cost — bypassed via code generation.

Tools:

  • mailru/easyjson — struct → dedicated marshaller code
  • pquerna/ffjson — similar concept
  • mvdan/gogenerate, vektra/mockery — across various domains
easyjson example
//go:generate easyjson -all user.go

//easyjson:json
type User struct {
	Name  string `json:"name"`
	Email string `json:"email"`
}

Generated user_easyjson.go — User-specific MarshalJSON / UnmarshalJSON. Handles bytes directly without reflect, so it’s fast.

Benchmark results — sometimes 2–5x faster than encoding/json. The tradeoff is — extra build step and auto-generated code mixed into PRs.

protobuf / gRPC — the apex of auto-generation #

protoc --go_out=. user.proto

From a .proto file — Go structs, marshallers, gRPC server/client code all auto-generated. The biggest code-generation case you’ll meet in practice.

generated
user.pb.go         ← struct + Marshal/Unmarshal
user_grpc.pb.go    ← gRPC server/client

Tens of thousands of lines — from a single .proto. This pipeline is a standard tool for microservices.

Practical workflow #

Tie it into a Makefile #

Makefile
.PHONY: gen
gen:
	go generate ./...

.PHONY: build
build: gen
	go build ./...

Generation as a pre-build step. Auto-generated files are usually committed to git (so other developers can build without the tools).

Verify in CI #

That generated files — are always up to date.

CI script
go generate ./...
git diff --exit-code     # fail if there are changes after generate

If a PR is missing a new auto-generated file, CI catches it.

How far to auto-generate #

Avoid:

  • Code with frequently changing logic — the generate step becomes a burden
  • Large generated files that make PR review hard — real changes are buried
  • Small projects where external-tool dependencies are a burden

Natural fit:

  • String methods on enums, JSON marshallers — based on static information
  • Interface mocks
  • Spec → code transformations like protobuf / OpenAPI

The rule is simple — once you’ve written the same pattern by hand 5+ times, it’s time to consider generation.

Pitfall — editing generated files directly #

// Code generated by ...; DO NOT EDIT.

When you see this header — never edit directly. The next generate run will overwrite it. If you really need to change it — modify the generator tool or the template.

Pitfall — pin generator tool versions #

pinning tool versions to the module
//go:build tools
// +build tools

package tools

import (
	_ "golang.org/x/tools/cmd/stringer"
	_ "go.uber.org/mock/mockgen"
)

In tools.go — import the tools you use (excluded from normal builds via build tag). go.mod pins versions. Guarantees the whole team uses the same tool versions.

Series wrap-up — looking back at 7 Advanced posts #

Summarizing the 7 posts:

  1. Concurrency patterns — pipeline, fan-out/fan-in, semaphore
  2. Memory model and sync — Mutex, atomic, Once
  3. Generics — type parameter, constraint
  4. reflect — handling types at runtime
  5. unsafe and cgo — outside the safe zone
  6. Profiling — pprof, benchmark, trace
  7. Code generation (this post)

These are cases where the limits of the tools from Basics/Intermediate become visible — patterns for solid concurrency, synchronization of shared memory, polymorphism via generics, boundary crossing with reflect/unsafe/cgo, measurement and automation. Where these tools all show up together is usually framework or library code.

Next steps #

The next series (#1 First HTTP Server) is Go Practice in 6 posts. We put the tools we’ve learned to work by building an actual backend — from net/http, ServeMux, JSON, and DB integration to middleware, testing, and deployment.

Wrap-up #

What we covered:

  • //go:generate + go generate ./... — the standard for compile-time code generation
  • stringer — String method for enums
  • mockgen — interface mocks
  • easyjson / protoc — serialization acceleration, RPC code
  • Auto-generated files — committed to git, never edited directly, freshness verified in CI
  • Tool versions pinned via tools.go
  • If reflect is on the hot path — consider code generation

That wraps up the 7 Advanced posts.

X