Build, code generation and cgo¶
How a Go program becomes a deployable artefact: generated source, build tags, version stamping, cross-compilation, and the one thing that makes all of it harder — cgo.
go:generate¶
A //go:generate comment records a command. go generate finds them
and runs them; go build never does:
That separation is deliberate: generation is a step you take and commit, not something that happens on every build. Anyone cloning the repo can build without the generator installed.
The directive can run anything — a code generator, a schema compiler, a template tool. It runs in the file's directory with the package's environment.
Many projects skip go:generate and drive generation from a Makefile
instead, which keeps every build step in one visible place rather than
scattered through source files. Either is fine; mixing them means
nobody knows which produced what.
Generated files are read-only¶
Generated Go carries a standard first line:
That exact form — matching ^// Code generated .* DO NOT EDIT\.$ — is
recognised by tooling. Linters skip these files, and reviewers know not
to comment on them.
Commit generated files. It keeps the build reproducible without the generator, makes the diff visible in review, and means CI does not need the tool installed. The cost is remembering to regenerate; a CI check that regenerates and fails on a diff closes that gap.
Build tags¶
A //go:build line at the top of a file decides whether it is compiled
at all. It must appear before the package clause, with a blank
line after it:
Two files, one identifier, and the right one is chosen per target:
$ go list -f '{{.GoFiles}}' .
[only_other.go pill.go]
$ GOOS=linux go list -f '{{.GoFiles}}' .
[only_linux.go pill.go]
go list is the quick way to check what a tag combination actually
selects — guessing is how you end up with a file nothing compiles.
Filename suffixes do the same thing implicitly: foo_linux.go,
foo_windows.go, foo_amd64.go, and foo_test.go. Prefer the suffix
for platform variants; use an explicit tag for anything else.
Do not gate tests behind a custom tag¶
//go:build integration on a test file means it runs only with
-tags integration. If neither CI nor the local make test passes
that flag, the tests never run anywhere — and they look present in
the repo the whole time, which is worse than not having them.
Gate at runtime instead, where the skip is visible:
func TestAgainstDatabase(t *testing.T) {
dsn := os.Getenv("TEST_DATABASE_DSN")
if dsn == "" {
t.Skip("TEST_DATABASE_DSN not set")
}
// ...
}
go test -v then prints the skip, so the absence is reported rather
than silent.
Version stamping with -ldflags¶
-X sets a string variable at link time, which is how a binary learns
its own version without a generated file:
It only works on a package-level string with no initialiser beyond a
constant. -ldflags "-s -w" additionally strips the symbol table and
DWARF data, producing a noticeably smaller binary — at the cost of
readable stack traces.
runtime/debug.ReadBuildInfo() gives you module versions and VCS
information the toolchain records automatically, which covers many
cases without any flags at all.
Cross-compilation¶
Set two variables. No toolchain to install, no container needed:
GOOS=linux GOARCH=amd64 go build -o app-linux-amd64 ./cmd/api
GOOS=linux GOARCH=arm64 go build -o app-linux-arm64 ./cmd/api
GOOS=windows GOARCH=amd64 go build -o app-windows.exe ./cmd/api
GOOS=darwin GOARCH=arm64 go build -o app-darwin-arm64 ./cmd/api
All four build from one machine. go tool dist list prints every
supported pair.
Other flags worth knowing: -trimpath removes local filesystem paths
from the binary, which you want for reproducible builds and for not
leaking your home directory into stack traces.
Containers¶
Because the output is a static binary, the image can be nearly empty:
FROM golang:1.27 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o /out/app ./cmd/api
FROM gcr.io/distroless/static-debian12
COPY --from=build /out/app /app
ENTRYPOINT ["/app"]
Copying go.mod/go.sum and running go mod download before the rest
of the source is the layer-caching trick: dependencies re-download only
when those two files change.
CGO_ENABLED=0 is what allows a static base image. With cgo on, the
binary needs a libc and the final stage must provide one.
cgo¶
import "C" lets Go call C. It is how a package binds a C library —
tree-sitter, SQLite, image codecs — and it changes the rules:
- Cross-compilation stops being free. You now need a C cross-compiler for every target.
- Static linking gets harder, so
distroless/staticis out. - Builds are slower, because a C compiler runs.
- Memory is yours to manage. The Go garbage collector does not see C allocations, so anything the C side allocates must be released explicitly:
Forget that Close and you have a leak the GC will never reclaim and
pprof's heap profile will not show, because the memory is not on the
Go heap.
- Panics do not cross the boundary, and a crash in C takes the process down with no Go stack trace.
Keep CGO_ENABLED=0 unless something genuinely requires it, and put
the requirement in the README — a developer whose build suddenly needs
a C toolchain deserves to know why.
The library's own API is then just a library to learn. What is
Go-specific is everything above: the build flags, the lost
cross-compilation, and the manual Close. Those are the parts that
will surprise you; the C binding's surface is documentation.
From Python:
go buildreplaces the entire packaging stack — there is no wheel, no virtualenv, no interpreter to ship. Build tags are conditional compilation rather than runtimesys.platformchecks. cgo is the ctypes/C-extension trade-off, and it costs about as much.
Quick reference¶
| Task | Form |
|---|---|
| record a generator | //go:generate cmd args, run with go generate ./... |
| mark generated code | // Code generated by X. DO NOT EDIT. |
| conditional file | //go:build tag before package, blank line after |
| platform variants | _linux.go, _windows.go suffixes |
| check what is selected | go list -f '{{.GoFiles}}' . |
| gate integration tests | an env var and t.Skip — not a build tag |
| stamp a version | -ldflags "-X main.version=1.2.3" |
| smaller binary | -ldflags "-s -w", -trimpath |
| cross-compile | GOOS=… GOARCH=… go build |
| static binary for a container | CGO_ENABLED=0 |
| C memory | defer x.Close(), always |
Sources¶
go generate— go.dev/blog/generate- Build constraints — pkg.go.dev/cmd/go#hdr-Build_constraints
- Generated code convention — go.dev/s/generatedcode
go buildflags — pkg.go.dev/cmd/go#hdr-Compile_packages_and_dependenciesruntime/debug.ReadBuildInfo— pkg.go.dev/runtime/debug#ReadBuildInfo- cgo — pkg.go.dev/cmd/cgo