The Comment That Writes Code: go generate and stringer

How the //go:generate directive turns a comment into a build step, why go build never runs it, and what running stringer against an int enum actually produces — a committed, reviewable String() method. Compiled and run against Go 1.26.5.

Some code is too mechanical to write by hand and too error-prone to leave written by hand. The String() method for an enum is the classic case: fifteen constants, and you want each to print its own name instead of a number. Writing that method is tedious, and worse, it drifts. Someone inserts a new constant in the middle, the numbers shift, and now the method returns the wrong name — with no compiler complaint. This is exactly the kind of code a machine should generate from the source of truth. Go ships a convention for that, go generate, and this chapter runs it end to end. Every command and every line of generated source below was produced against Go 1.26.5.

What go generate actually is

go generate is smaller than it looks. It scans your source files for a special comment, the //go:generate directive, and runs the shell command that follows it — that is the entire mechanism. It does not know what code generation is, it has no opinion about what the command does, and it imposes no framework — it finds directives and executes commands, in file order, in the package’s directory.

The directive is a comment with no space after the slashes:

//go:generate go run golang.org/x/tools/cmd/stringer -type=State

The single most important fact about go generate is what does not run it. go build does not run go generate. Neither does go test, nor go install. Nothing runs it except you, typing go generate explicitly. This is a deliberate and load-bearing design choice — generation is a step an author performs and then commits the result of, not something that happens invisibly on every build. The person who checks out your repo and runs go build gets the generated file you committed, byte for byte, without needing the generator installed at all. We’ll prove the “build doesn’t run it” claim in a moment — because it surprises people.

An enum worth generating for

Here is a small state type, the source of truth we want names for:

package main

//go:generate go run golang.org/x/tools/cmd/stringer -type=State

// State is the lifecycle of a background job.
type State int

const (
	Pending State = iota
	Running
	Done
	Failed
)

And a program that prints each state with %s, which asks the value to describe itself:

package main

import "fmt"

func main() {
	for _, s := range []State{Pending, Running, Done, Failed} {
		fmt.Printf("%d -> %s\n", int(s), s)
	}
}

Build and run it before generating anything, and you see the problem in the flesh:

$ go build -o app . && ./app
0 -> %!s(main.State=0)
1 -> %!s(main.State=1)
2 -> %!s(main.State=2)
3 -> %!s(main.State=3)

That %!s(main.State=0) is fmt reporting that State has no String() method, so it has nothing to print but the underlying integer and a complaint. Notice the build succeeded — a missing String() is not a compile error, it is a runtime disappointment. This is the first half of the proof that go build ignores the directive. The directive is sitting right there in the file — and the build walked straight past it.

Running stringer for real

stringer is the reference generator, part of golang.org/x/tools. The directive runs it with go run, which fetches and builds the tool on demand. In this module the package needed one go.sum entry first:

$ go get golang.org/x/tools/cmd/stringer
go: upgraded golang.org/x/tools v0.47.0 => v0.48.0

With that in place, run generation over the package:

$ go generate ./.
$ ls
main.go  state.go  state_string.go

A new file, state_string.go, appeared. (The network fetch of the tool succeeded here; in an environment with no module proxy access it would fail at the go run, and you’d vendor the tool or install it ahead of time.) Here is what stringer wrote, in full:

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

package main

import "strconv"

func _() {
	// An "invalid array index" compiler error signifies that the constant values have changed.
	// Re-run the stringer command to generate them again.
	var x [1]struct{}
	_ = x[Pending-0]
	_ = x[Running-1]
	_ = x[Done-2]
	_ = x[Failed-3]
}

const _State_name = "PendingRunningDoneFailed"

var _State_index = [...]uint8{0, 7, 14, 18, 24}

func (i State) String() string {
	idx := int(i) - 0
	if i < 0 || idx >= len(_State_index)-1 {
		return "State(" + strconv.FormatInt(int64(i), 10) + ")"
	}
	return _State_name[_State_index[idx]:_State_index[idx+1]]
}

This repays a close read — it is smarter than a hand-written method. The names are stored as one packed string, "PendingRunningDoneFailed", with an index table [0 7 14 18 24] marking the boundaries — so String() returns a slice of that constant with zero allocation. Out-of-range values fall through to a State(7)-style rendering instead of panicking. And that odd func _() at the top is a compile-time guard: it indexes a one-element array with Pending-0, Running-1, and so on, so if anyone renumbers the constants without regenerating, those expressions go out of range and the package stops compiling with an “invalid array index” error. The generated code defends itself against becoming stale. The first line, DO NOT EDIT, is a recognized convention — linters and tools respect it and leave the file alone.

Rebuild and run, and the same %s now has a method to call:

$ go build -o app . && ./app
0 -> Pending
1 -> Running
2 -> Done
3 -> Failed

Names, not numbers, from source that we did not write and will not maintain by hand.

Proving the build does not generate

The claim deserves a clean demonstration, so here it is directly. Delete the generated file, then build without generating:

$ rm state_string.go
$ go build -o app . && ./app
0 -> %!s(main.State=0)
1 -> %!s(main.State=1)
2 -> %!s(main.State=2)
3 -> %!s(main.State=3)

The build succeeded and the fallback is back. go build saw the //go:generate directive, as it always does — and did nothing with it. Only go generate brings the file back:

$ go generate ./. && go build -o app . && ./app
0 -> Pending

This is the workflow to internalize: you run go generate when the source of truth changes, you commit the generated file alongside your code, and the build treats it like any other hand-written source. In a repo you’d run go generate ./... to sweep every package, usually in CI as a check that the committed output matches a fresh run.

The philosophy, and the other generators

The discipline around generated code is the interesting part, and it is consistent across the ecosystem. Generated code is committed, not produced at build time, so a checkout builds without the generator present and code review sees the real output in the diff. It is deterministic — the same input produces the same file, which is what makes the “is the committed output stale?” CI check possible. And it carries the DO NOT EDIT header so nobody hand-patches a file that the next generation will overwrite.

stringer is the gateway example, but the same directive drives the heavy hitters. protoc with the Go plugin turns a .proto schema into Go structs and gRPC stubs. mockgen and moq read an interface and write a test double that implements it — so your mocks track the interface instead of rotting beside it. sqlc compiles SQL queries into typed Go functions. All of them are invoked the same way: a //go:generate line naming the tool, go generate to run it, the output committed. The pattern is always the same because go generate itself is so small. It runs a comment. What the comment does is up to the tool — and the tools are where the leverage is.

Final thoughts

go generate is a convention, not a feature: a //go:generate comment names a command, go generate runs it, and go build pointedly does not. That last part is what makes generated code trustworthy in a Go project — the file in the repo is the file that ships, reviewed in the diff and built by everyone without the generator installed. stringer shows the whole shape in miniature — an enum is the source of truth, the tool derives a self-defending String() from it, and you commit the result. When you meet protobuf stubs or generated mocks later, they are the same move at larger scale. Next we turn to the code you didn’t write and can’t see, the dependency graph, and the tools Go gives you to know what is actually in your build.

Next: know what you ship — auditing the module graph and scanning it for known vulnerabilities.

Comments