DEV Community

Cover image for go:generate in Go: Code Generation Without a Build System
Gabriel Anhaia
Gabriel Anhaia

Posted on

go:generate in Go: Code Generation Without a Build System


You add a new value to an enum. A Status constant, say StatusArchived,
right after StatusActive. The code compiles. The tests pass. Three weeks
later a support ticket comes in: a dashboard is showing Status(4) instead
of a readable label. You forgot to add the case to the hand-written
String() method, and Go had no reason to warn you. The method was never
wrong, it was just incomplete, and incomplete Go code compiles fine.

That gap is what go:generate closes. It is not a build system, not a
plugin framework, not a macro language. It is a comment convention plus one
subcommand: go generate. You write a magic comment above a type, run one
command, and a tool writes the boring code you would otherwise write by hand
and forget to update.

What the directive actually is

A go:generate directive is a specially formatted comment. The Go toolchain
ignores it during a normal build. It only does anything when you explicitly
run go generate.

package billing

//go:generate stringer -type=Status

type Status int

const (
    StatusDraft Status = iota
    StatusActive
    StatusArchived
)
Enter fullscreen mode Exit fullscreen mode

Two rules trip people up. The comment must start with //go:generate with
no space after the slashes. And go generate runs the command in the
package directory, scanning files in order, executing each directive as a
shell-less exec of the named program. That program has to be on your PATH.

Run it:

go generate ./...
Enter fullscreen mode Exit fullscreen mode

Go finds the directive, runs stringer, and writes a
status_string.go file next to your source. Add StatusArchived to the
const block, run the command again, and the generated file catches up. The
boilerplate is no longer a thing you remember to maintain. It is output.

Stringer: the canonical example

stringer ships as part of golang.org/x/tools. Install it once:

go install golang.org/x/tools/cmd/stringer@latest
Enter fullscreen mode Exit fullscreen mode

The generated file gives you a String() method backed by a lookup into a
single packed string plus an index array. No map, no switch, no
allocation on the hot path. Here is the shape of what it writes:

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

func (i Status) String() string {
    if i < 0 || i >= Status(len(_Status_index)-1) {
        return "Status(" + strconv.FormatInt(int64(i), 10) + ")"
    }
    return _Status_name[_Status_index[i]:_Status_index[i+1]]
}
Enter fullscreen mode Exit fullscreen mode

The first line matters. DO NOT EDIT is a convention the whole ecosystem
reads. Linters skip these files. Reviewers know not to touch them. And CI can
check that nobody did.

Why not reflection

You could get the same label at runtime with reflection or a map[Status]string.
Both work. Both cost you something go generate does not.

A map[Status]string is a manual table. It has the exact problem the
hand-written String() had: nothing forces it to stay in sync with the
const block. Add a value, forget the map entry, get a silent zero string.

Reflection is worse for this job. It moves the work to runtime, so a typo or
a missing tag becomes a production error instead of a build-time one. It
allocates. And it is opaque: you cannot open the generated file and read
exactly what will run, because there is no generated file. With go:generate
the output is a plain .go file you can read, diff, and step through in a
debugger.

The trade is real. Codegen adds a step and a tool dependency. Reflection is
zero setup. The line to draw: when the shape is known at compile time and
correctness depends on covering every case, generate the code. When the
shape is only known at runtime (decoding arbitrary JSON, a plugin loading
unknown types), reflection is the right tool. encoding/json uses reflection
for a reason. Your enum labels do not need it.

mockgen: generating test doubles

The second place go:generate earns its keep is test mocks. Say you have a
port your service depends on:

package payment

type Gateway interface {
    Charge(ctx context.Context, cents int64) (string, error)
    Refund(ctx context.Context, txID string) error
}
Enter fullscreen mode Exit fullscreen mode

Hand-writing a mock for that interface means a struct, a func field per
method, and the plumbing to record calls. Every time you add a method to
Gateway, you edit the mock too, or the test package stops compiling. That
is friction that pushes people toward not mocking at all.

mockgen from the go.uber.org/mock
project (the maintained fork of the old golang/mock) writes it for you:

//go:generate go run go.uber.org/mock/mockgen -source=gateway.go -destination=mock_gateway_test.go -package=payment
Enter fullscreen mode Exit fullscreen mode

Run go generate ./... and you get a MockGateway with a full call
recorder and an EXPECT() API:

func TestCharge(t *testing.T) {
    ctrl := gomock.NewController(t)
    gw := NewMockGateway(ctrl)

    gw.EXPECT().
        Charge(gomock.Any(), int64(1500)).
        Return("tx_123", nil)

    svc := NewService(gw)
    if err := svc.Buy(context.Background(), 1500); err != nil {
        t.Fatal(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

Note the go run go.uber.org/mock/mockgen form instead of a bare
mockgen. That runs the version pinned in your go.mod, so every developer
and the CI box generate identical mocks without a separate install step. Pin
the tool as a dependency and the generated output stops drifting between
machines.

Writing your own generator

You do not need a framework to write a generator. A generator is any program
that reads something and prints Go code to a file. The standard library
gives you text/template and go/format for exactly this.

Here is a generator that turns a small list of event names into typed
constants plus a registry. In gen/main.go:

package main

import (
    "go/format"
    "os"
    "text/template"
)

var events = []string{"UserCreated", "UserDeleted"}

const tmpl = `// Code generated by gen; DO NOT EDIT.
package events

type Event string

const (
{{range .}} {{.}} Event = "{{.}}"
{{end}})
`
Enter fullscreen mode Exit fullscreen mode

The input is a slice and the output is a template. The main function
executes the template into a buffer, formats it, and writes the file:

func main() {
    t := template.Must(template.New("e").Parse(tmpl))
    var buf []byte
    w := &byteWriter{&buf}
    if err := t.Execute(w, events); err != nil {
        panic(err)
    }
    out, err := format.Source(buf)
    if err != nil {
        panic(err)
    }
    os.WriteFile("events_gen.go", out, 0o644)
}

type byteWriter struct{ b *[]byte }

func (w *byteWriter) Write(p []byte) (int, error) {
    *w.b = append(*w.b, p...)
    return len(p), nil
}
Enter fullscreen mode Exit fullscreen mode

Wire it up with a directive in the package it generates into:

//go:generate go run ./gen
Enter fullscreen mode Exit fullscreen mode

The format.Source call is the part people skip and regret. Templates
produce ragged whitespace. Running the output through go/format before
writing means the generated file is gofmt-clean, so it does not show up as
noise in every diff. It is the same formatter gofmt uses, so the result is
byte-identical to what a human running gofmt would produce.

Keeping generated files honest in CI

Generated code has one failure mode: someone changes the source, forgets to
regenerate, and commits. Now the checked-in file and the source disagree.
The build is green because the stale file still compiles. This is the enum
bug from the opening, one layer up.

The fix is a CI step that regenerates everything and fails if anything
changed. Run generation, then ask git whether the tree is dirty:

go generate ./...
git diff --exit-code
Enter fullscreen mode Exit fullscreen mode

git diff --exit-code returns non-zero when there is an uncommitted change.
If regeneration produced a diff, the committed files were stale, and the job
fails with the exact diff printed for the author to see. Put those two lines
in your pipeline before the test step and stale generated code cannot reach
main.

One more guard. The DO NOT EDIT header is not for humans alone. Tools like
golangci-lint recognize the
^// Code generated .* DO NOT EDIT\.$ pattern and skip linting those files,
which keeps generated code out of your lint report where you cannot fix it
anyway. Emit that header from every generator you write and the whole
toolchain treats the file correctly for free.

When codegen wins, and when it does not

Reach for go:generate when the code is mechanical, the shape is known at
compile time, and correctness means covering every case: enum stringers,
interface mocks, protobuf and gRPC stubs, typed accessors over a schema.
The generated file is readable, debuggable, and allocation-free, and CI can
prove it is in sync.

Skip it when the shape is genuinely dynamic, when a three-line helper is
clearer than a generator plus a template, or when you would spend more time
maintaining the generator than the code it emits. Codegen is a tool for
removing repetition you can describe precisely. If you cannot describe the
pattern precisely, you do not have a codegen problem yet.

The Go position is quietly opinionated here. The language left macros out on
purpose, then shipped go generate so you could still automate boilerplate,
out in the open, as files you commit and review. That is the whole design:
no hidden build magic, just a comment and a command.

Code generation sits right on the boundary between the language and your
architecture, which is exactly where it is easy to get wrong. The Complete
Guide to Go Programming
goes deep on the runtime and stdlib pieces this
leans on, text/template, go/format, and how the toolchain treats
generated files. Hexagonal Architecture in Go is the one to read for
keeping generators at the right boundary, so your ports own their mocks and
your generated code never leaks into the domain.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)