Skip to content

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.

GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o app ./cmd/api

go:generate

A //go:generate comment records a command. go generate finds them and runs them; go build never does:

//go:generate stringer -type=Pill

type Pill int

const (
    Placebo Pill = iota
    Aspirin
    Ibuprofen
)
go generate ./...

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:

// Code generated by stringer. DO NOT EDIT.

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:

//go:build linux

package main

const platform = "linux"
//go:build !linux

package main

const platform = "other"

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:

var version = "dev"

func main() { fmt.Println("version:", version) }
$ go run -ldflags "-X main.version=1.2.3" .
version: 1.2.3

$ go run .
version: dev

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/static is 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:
tree := parser.Parse(source)
defer tree.Close()   // frees C memory — not optional

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 build replaces the entire packaging stack — there is no wheel, no virtualenv, no interpreter to ship. Build tags are conditional compilation rather than runtime sys.platform checks. 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