Day 63 — Build tags & cross-compile
July 30, 2026
Day 63 — Build tags & cross-compile
Stage VII · ~3h
Goal: Produce multi-OS/arch binaries deliberately—using //go:build constraints, GOOS/GOARCH, and a static-first default—and document what you shipped.
Stage VII is ship quality. You stop treating “it builds on my laptop” as done. Artifacts, profiles, scans, and containers become first-class deliverables.
Why this day exists
Production Go almost always means more than one target:
- CI runners on Linux/amd64 building for Linux/arm64 (Raspberry Pi, Graviton, Apple Silicon in Linux VMs)
- Developers on macOS shipping Linux containers
- Optional platform-specific code (syscall wrappers, OS-only features) without
#ifdefchaos
Build tags and the cross-compile matrix are how the toolchain expresses that. Misusing cgo or ignoring tags is a classic “works here, fails in CI” class of bugs.
Theory 1 — Build constraints (//go:build)
Go files can be included or excluded at compile time with build constraints.
Modern syntax (preferred)
Place the constraint before the package clause, with a blank line after. Multiple lines AND together when both appear (prefer a single line with && / || / !).
Common tag families
| Constraint | Meaning |
|---|---|
linux, darwin, windows |
GOOS |
amd64, arm64, 386 |
GOARCH |
cgo / !cgo |
Whether cgo is enabled |
Custom (//go:build integration) |
Your tags via -tags |
Example — OS-specific files in one package
internal/fs/
path.go # shared API
path_unix.go # //go:build unix
path_windows.go # //go:build windows
// path.go
package fs
// OpenDataDir returns the platform data directory for the app.
func OpenDataDir(app string) (string, error) {
return openDataDir(app)
}// path_unix.go
//go:build unix
package fs
import "os"
func openDataDir(app string) (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return home + "/.local/share/" + app, nil
}// path_windows.go
//go:build windows
package fs
import "os"
func openDataDir(app string) (string, error) {
base := os.Getenv("LOCALAPPDATA")
if base == "" {
return "", errNoLocalAppData
}
return base + "\\" + app, nil
}Callers import package fs only. The linker sees one openDataDir per build.
Custom tags for optional features
Use tags for compile-time feature gates (integration tests, stub drivers). Prefer runtime config for product feature flags (Day 79).
Legacy // +build lines
You may still see:
Prefer //go:build. Both are still understood; do not mix confusingly in new code.
Theory 2 — Cross-compile with GOOS / GOARCH
Cross-compilation is a first-class Go feature for pure-Go (and many cgo-free) programs:
GOOS=linux GOARCH=arm64 go build -o bin/app-linux-arm64 .
GOOS=darwin GOARCH=arm64 go build -o bin/app-darwin-arm64 .
GOOS=linux GOARCH=amd64 go build -o bin/app-linux-amd64 .List known pairs:
What the env vars mean
| Variable | Role |
|---|---|
GOOS |
Target operating system |
GOARCH |
Target architecture |
GOARM |
ARM generation (e.g. 7 for older 32-bit) when relevant |
CGO_ENABLED |
0 disables cgo (usually required for pure cross-compile) |
Inspect a binary after build:
Theory 3 — cgo is the cross-compile cliff
If any package uses cgo (including transitive deps), cross-compiling needs a target C toolchain and correct CC. For most services and CLIs in this volume:
| Choice | Trade-off |
|---|---|
CGO_ENABLED=0 |
Portable static-ish binaries; no glibc/musl host surprises |
| cgo enabled | Need C libs; larger surface; hard cross-compile |
SQLite note: pure-Go drivers (e.g. modernc.org/sqlite) vs CGO sqlite differ on this axis. Choose deliberately before containerizing (Day 68).
Trimpath and reproducible-ish builds
-trimpathstrips local absolute paths from the binary
-ldflags="-s -w"strips symbol/DWARF tables (smaller; harder to debug)—use for release artifacts, not always for local pprof days
Embed version at link time:
VERSION=$(git describe --tags --always --dirty)
go build -ldflags="-X main.version=${VERSION}" -o bin/app .package main
var version = "dev"
func main() {
// print version in --version / /version
_ = version
}Theory 4 — //go:build vs runtime runtime.GOOS
| Mechanism | When to use |
|---|---|
| Build tags / file suffixes | Different APIs, different syscalls, exclude whole files |
runtime.GOOS / runtime.GOARCH |
Small branches in shared code |
Prefer file split when half the file is OS-specific. Prefer runtime for a one-line path separator style choice already covered by path/filepath.
Worked example — multi-arch release script
#!/usr/bin/env bash
# scripts/build-release.sh
set -euo pipefail
APP="${APP:-day63}"
VERSION="${VERSION:-$(git describe --tags --always --dirty 2>/dev/null || echo dev)}"
mkdir -p bin
targets=(
"linux/amd64"
"linux/arm64"
"darwin/arm64"
"darwin/amd64"
)
for t in "${targets[@]}"; do
os="${t%/*}"
arch="${t#*/}"
out="bin/${APP}-${os}-${arch}"
echo "building $out"
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build \
-trimpath \
-ldflags="-s -w -X main.version=${VERSION}" \
-o "$out" .
done
ls -la bin/Minimal main.go for the lab:
package main
import (
"fmt"
"runtime"
)
var version = "dev"
func main() {
fmt.Printf("%s %s/%s version=%s\n", "day63", runtime.GOOS, runtime.GOARCH, version)
}Lab
Suggested workspace: ~/lab/90daysofx/01-go/day63
- Create a tiny module (
example.com/day63) with themainabove.
- Add one package with two OS-specific files (
//go:build unix///go:build windowsordarwin/linux) that return a platform string used bymain.
- Build at least linux/arm64 and darwin/arm64 (or linux/amd64 + your host)—with
CGO_ENABLED=0.
- Record
fileoutput andgo version -mfor each artifact inNOTES.md.
- Optional: add
scripts/build-release.shand a custom tag file compiled only with-tags=debug.
Commands to keep
go env GOOS GOARCH CGO_ENABLED
go tool dist list | head
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o bin/app-linux-arm64 .
file bin/app-linux-arm64
go version -m bin/app-linux-arm64Common gotchas
| Gotcha | Fix |
|---|---|
| Cross-compile fails with cgo | CGO_ENABLED=0 or install a cross C toolchain deliberately |
| Wrong file included | Check //go:build line + blank line before package |
| Tag never applied | Pass -tags=name to go build/go test |
| “It runs on my Mac binary in Linux” | You shipped the wrong GOOS—check file |
| Debug symbols bloating CI artifacts | Use -ldflags="-s -w" for release only |
| Import cycle from “platform helpers” | Keep OS files in the same package; shared API in non-tagged file |
Checkpoint
- Explain
//go:buildplacement and boolean operators
- Produce two different OS/arch artifacts with
CGO_ENABLED=0
- Show
file+go version -mevidence for both
- At least one OS-specific source file pair (or documented reason you used runtime only)
NOTES.mdlists targets and cgo policy
Commit
Write three personal gotchas before continuing.
Tomorrow
Day 64 — vet & staticcheck: make the module lint-clean and understand private module / replace hygiene before you optimize anything.