Skip to content

Code Generation Advanced

Introduction

Code generation is a first-class strategy in Go for eliminating boilerplate, enforcing type safety, and creating performant serialization — all before your code compiles. Instead of relying on runtime reflection (slow, untyped) or generics (limited scope), Go encourages generating concrete, type-safe code at development time via go generate.

The ecosystem is built on this principle: stringer for enum strings, mockgen for test mocks, protoc for gRPC, sqlc for type-safe SQL, and ent for ORM code. Understanding code generation is essential for building production Go systems.

Syntax & Usage

The go generate Directive

go generate scans Go files for special comments and runs the specified commands. It is not invoked automatically by go build — you run it explicitly.

//go:generate stringer -type=Color
//go:generate mockgen -source=repo.go -destination=mock_repo.go

package mypackage
# Run all generators in current package
go generate ./...

# Run generators in specific package
go generate ./internal/models/...

# Verbose output
go generate -v ./...

# Run only generators matching a regex
go generate -run "stringer" ./...

go generate runs at development time

go generate is a developer tool, not part of the build process. Generated files should be committed to version control so that go build works without running generators.

stringer — Enum String Methods

The stringer tool generates String() methods for integer-based enums, implementing fmt.Stringer.

package status

//go:generate stringer -type=Status

type Status int

const (
    Pending Status = iota
    Active
    Suspended
    Closed
)

Running go generate produces status_string.go:

// Code generated by "stringer -type=Status"; DO NOT EDIT.

func (i Status) String() string {
    switch i {
    case Pending:
        return "Pending"
    case Active:
        return "Active"
    case Suspended:
        return "Suspended"
    case Closed:
        return "Closed"
    }
    return "Status(" + strconv.FormatInt(int64(i), 10) + ")"
}

Install with: go install golang.org/x/tools/cmd/stringer@latest

mockgen — Test Mock Generation

mockgen generates mock implementations of interfaces for use in unit tests.

package repository

//go:generate mockgen -source=user_repo.go -destination=mock_user_repo.go -package=repository

type UserRepository interface {
    GetByID(ctx context.Context, id int64) (*User, error)
    Create(ctx context.Context, user *User) error
    Delete(ctx context.Context, id int64) error
}

Using the generated mock in tests:

func TestGetUser(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    mockRepo := NewMockUserRepository(ctrl)
    mockRepo.EXPECT().
        GetByID(gomock.Any(), int64(42)).
        Return(&User{ID: 42, Name: "Alice"}, nil)

    svc := NewUserService(mockRepo)
    user, err := svc.GetUser(context.Background(), 42)

    assert.NoError(t, err)
    assert.Equal(t, "Alice", user.Name)
}

protoc — Protocol Buffers and gRPC

Protocol buffer definitions generate Go structs and gRPC service stubs:

//go:generate protoc --go_out=. --go-grpc_out=. proto/user.proto
// proto/user.proto
syntax = "proto3";
option go_package = "myapp/pb";

service UserService {
    rpc GetUser(GetUserRequest) returns (User);
}

message GetUserRequest {
    int64 id = 1;
}

message User {
    int64  id    = 1;
    string name  = 2;
    string email = 3;
}

AST Manipulation — Writing Custom Generators

Go provides go/ast, go/parser, go/printer, and go/token for parsing and manipulating Go source code programmatically.

package main

import (
    "go/ast"
    "go/parser"
    "go/printer"
    "go/token"
    "os"
    "strings"
)

func main() {
    fset := token.NewFileSet()
    file, err := parser.ParseFile(fset, "input.go", nil, parser.ParseComments)
    if err != nil {
        panic(err)
    }

    // Find all exported struct types
    ast.Inspect(file, func(n ast.Node) bool {
        ts, ok := n.(*ast.TypeSpec)
        if !ok || !ts.Name.IsExported() {
            return true
        }
        if _, ok := ts.Type.(*ast.StructType); ok {
            // Generate code for this struct (e.g., JSON marshaler, validator)
            generateForStruct(ts)
        }
        return true
    })

    printer.Fprint(os.Stdout, fset, file)
}

Practical Custom Generator — JSON Enum Marshaler

A complete generator that creates JSON marshal/unmarshal methods for enums:

// cmd/enumjson/main.go
package main

import (
    "bytes"
    "flag"
    "fmt"
    "go/ast"
    "go/parser"
    "go/token"
    "os"
    "strings"
    "text/template"
)

var typeName = flag.String("type", "", "type name for enum")

const tmpl = `// Code generated by enumjson; DO NOT EDIT.
package {{.Package}}

import "encoding/json"

var _{{.Type}}NameMap = map[{{.Type}}]string{
{{- range .Values}}
    {{.}}: "{{.}}",
{{- end}}
}

var _{{.Type}}ValueMap = map[string]{{.Type}}{
{{- range .Values}}
    "{{.}}": {{.}},
{{- end}}
}

func (e {{.Type}}) MarshalJSON() ([]byte, error) {
    if name, ok := _{{.Type}}NameMap[e]; ok {
        return json.Marshal(name)
    }
    return nil, fmt.Errorf("invalid {{.Type}}: %d", e)
}

func (e *{{.Type}}) UnmarshalJSON(data []byte) error {
    var s string
    if err := json.Unmarshal(data, &s); err != nil {
        return err
    }
    if val, ok := _{{.Type}}ValueMap[s]; ok {
        *e = val
        return nil
    }
    return fmt.Errorf("invalid {{.Type}}: %q", s)
}
`

func main() {
    flag.Parse()

    fset := token.NewFileSet()
    pkgs, err := parser.ParseDir(fset, ".", nil, 0)
    if err != nil {
        fmt.Fprintf(os.Stderr, "parse error: %v\n", err)
        os.Exit(1)
    }

    var pkgName string
    var values []string

    for name, pkg := range pkgs {
        pkgName = name
        for _, file := range pkg.Files {
            for _, decl := range file.Decls {
                gd, ok := decl.(*ast.GenDecl)
                if !ok || gd.Tok != token.CONST {
                    continue
                }
                for _, spec := range gd.Specs {
                    vs := spec.(*ast.ValueSpec)
                    if ident, ok := vs.Type.(*ast.Ident); ok && ident.Name == *typeName {
                        values = append(values, vs.Names[0].Name)
                    }
                    // Handle iota group where type is specified only on the first const
                    if vs.Type == nil && len(values) > 0 {
                        values = append(values, vs.Names[0].Name)
                    }
                }
            }
        }
    }

    t := template.Must(template.New("enum").Parse(tmpl))
    var buf bytes.Buffer
    t.Execute(&buf, map[string]any{
        "Package": pkgName,
        "Type":    *typeName,
        "Values":  values,
    })

    outFile := strings.ToLower(*typeName) + "_json.go"
    os.WriteFile(outFile, buf.Bytes(), 0644)
}

Usage in your source file:

//go:generate go run ./cmd/enumjson -type=Priority

type Priority int

const (
    Low    Priority = iota
    Medium
    High
    Critical
)

text/template for Code Generation

Go's text/template is the standard engine for code generators:

const structTemplate = `// Code generated; DO NOT EDIT.
package {{.Package}}

{{range .Structs}}
func (s *{{.Name}}) Validate() error {
    {{- range .Fields}}
    {{- if .Required}}
    if s.{{.Name}} == {{.ZeroValue}} {
        return fmt.Errorf("{{.Name}} is required")
    }
    {{- end}}
    {{- end}}
    return nil
}
{{end}}
`

go generate vs Generics vs Reflection

Approach When to Use Pros Cons
go generate Boilerplate, serialization, mocks Type-safe, zero runtime cost, full flexibility Extra build step, generated code to maintain
Generics Algorithms, containers, utility functions No build step, compiler-checked Limited constraints, no method generation
Reflection Dynamic dispatch, generic marshaling No build step, fully dynamic Slow, no type safety, complex code

Quick Reference

Tool / Package Purpose Install
go generate Run generator commands from comments Built-in
stringer String() for integer enums golang.org/x/tools/cmd/stringer
mockgen Interface mock generation go.uber.org/mock/mockgen
protoc-gen-go Protocol buffer Go structs google.golang.org/protobuf/cmd/protoc-gen-go
protoc-gen-go-grpc gRPC service stubs google.golang.org/grpc/cmd/protoc-gen-go-grpc
sqlc Type-safe SQL → Go github.com/sqlc-dev/sqlc
go/ast Parse Go source into AST Standard library
go/parser Parse Go files and packages Standard library
go/printer Pretty-print AST back to source Standard library
text/template Template-based generation Standard library

Best Practices

  1. Commit generated filesgo generate is a dev tool, not a build step. CI should not need to run generators; committing ensures go build always works.
  2. Add // Code generated ... DO NOT EDIT. header — Go tooling recognizes this header and excludes generated files from certain linters and reviews.
  3. Create a top-level generate.go — centralize //go:generate directives in one file per package for discoverability.
  4. Pin generator versions — use tools.go with a build constraint to track generator dependencies in go.mod:

    //go:build tools
    
    package tools
    
    import (
        _ "go.uber.org/mock/mockgen"
        _ "golang.org/x/tools/cmd/stringer"
    )
    
  5. Verify generated code is fresh in CI — run go generate ./... and then git diff --exit-code to catch stale generated files.

  6. Prefer generation over reflection — generated code is type-safe, has zero runtime overhead, and is debuggable. Use reflection only when the type is truly unknown at compile time.

Common Pitfalls

Generated files not committed to version control

# CI fails because generated files aren't checked in
$ go build ./...
# error: undefined: StatusString (stringer output missing)
Always commit generated files. If you rely on CI running go generate, you need all generator tools installed in the CI image — fragile and slow.

Generator directive placement

// WRONG: directive must be a comment in a .go file
# This won't work  go generate ignores non-.go files

// WRONG: must be exactly "//go:generate" with no space before "go"
// go:generate stringer -type=Status
The directive must be exactly //go:generate (no space after //) in a .go file.

Stale generated code

When you add a new enum value but forget to re-run go generate, the generated String() method won't include it. Add a CI check:

go generate ./...
git diff --exit-code || (echo "Generated files are stale" && exit 1)

Circular dependencies in generators

If your generator imports the package it generates code for, you get a circular dependency. Keep generators in a separate cmd/ directory that only reads source files — never imports the target package.

Performance Considerations

  • Zero runtime cost: Generated code is compiled like hand-written code — no reflection, no interface boxing, no dynamic dispatch. This is the primary advantage over reflection.
  • Build time: go generate runs external tools, which can be slow (especially protoc). Run generators only when source files change, not on every build.
  • Binary size: Generated code increases binary size proportionally to the number of types. For large protobuf schemas, this can be significant — consider splitting packages.
  • Stringer vs fmt.Sprintf: A generated String() method is a simple switch statement — O(1). The reflection-based alternative (fmt.Sprintf("%d", val)) is orders of magnitude slower for enum-to-string conversion.
  • mockgen overhead: Generated mocks are verbose but have negligible impact on test binary size. The alternative — hand-written mocks — is just as large and more error-prone.

Interview Tips

Interview Tip

"What is go generate and when would you use it?" go generate is a development-time tool that scans Go source files for //go:generate comments and runs the specified commands. It's used for generating string methods for enums, creating test mocks, generating protobuf/gRPC code, and building type-safe serializers. Generated files are committed to VCS — go generate is never part of the build itself.

Interview Tip

"When would you use code generation vs generics vs reflection?" Code generation for boilerplate that needs type-specific logic (serializers, validators, mocks). Generics for algorithms and containers that work across types with shared constraints. Reflection only as a last resort when types are truly unknown at compile time (think encoding/json). Generation gives zero runtime cost and full type safety.

Interview Tip

"How do you write a custom code generator in Go?" Parse source files with go/parser to get an AST, walk it with ast.Inspect to find target types, extract metadata (fields, types, tags), then use text/template to emit Go source code with the // Code generated ... DO NOT EDIT. header. Wire it up with a //go:generate directive.

Interview Tip

"How do you ensure generated code stays fresh?" In CI, run go generate ./... followed by git diff --exit-code. If there's a diff, the generated files are stale and the build should fail. Pin generator tool versions in a tools.go file tracked by go.mod to prevent version drift.

Key Takeaways

  • go generate runs commands from //go:generate comments — it's a dev tool, not a build step.
  • Always commit generated files and add the // Code generated ... DO NOT EDIT. header.
  • stringer generates String() for enums, mockgen creates test mocks, protoc generates gRPC stubs.
  • Write custom generators using go/ast + go/parser for AST manipulation and text/template for output.
  • Code generation produces type-safe, zero-overhead code — prefer it over reflection for known-at-compile-time types.
  • Pin generator versions in tools.go and verify freshness in CI with git diff --exit-code.
  • The trade-off: generated code adds files to maintain but eliminates runtime cost and type-safety gaps.