Day 63 — Build tags & cross-compile

Updated

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.

Note

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 #ifdef chaos

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)

//go:build linux && amd64

package platform

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

//go:build e2e

package e2e_test
go test -tags=e2e ./...

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:

// +build linux,amd64

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:

go tool dist list

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:

file bin/app-linux-arm64
go version -m bin/app-linux-arm64   # embedded build info / module versions

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:

CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o bin/app .
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

go build -trimpath -ldflags="-s -w" -o bin/app .
  • -trimpath strips 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

  1. Create a tiny module (example.com/day63) with the main above.
  2. Add one package with two OS-specific files (//go:build unix / //go:build windows or darwin/linux) that return a platform string used by main.
  3. Build at least linux/arm64 and darwin/arm64 (or linux/amd64 + your host)—with CGO_ENABLED=0.
  4. Record file output and go version -m for each artifact in NOTES.md.
  5. Optional: add scripts/build-release.sh and 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-arm64

Common 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:build placement and boolean operators
  • Produce two different OS/arch artifacts with CGO_ENABLED=0
  • Show file + go version -m evidence for both
  • At least one OS-specific source file pair (or documented reason you used runtime only)
  • NOTES.md lists targets and cgo policy

Commit

git add .
git commit -m "day63: build tags and cross-compile artifacts"

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.