Go doesn’t have a preprocessor. You can’t #ifdef. But it has build tags, which cover most of the useful cases with much less rope to hang yourself with, and I’ve come to appreciate them as more than just a niche OS-specific compilation tool.

The syntax is a comment at the top of a file:

//go:build linux && amd64

package myservice

The //go:build line (introduced in Go 1.17, replacing the older // +build syntax) is evaluated before compilation. If the expression is true for the current build, the file is compiled. If false, the file is ignored entirely.

Common predefined tags:

  • linux, darwin, windows, freebsd — OS
  • amd64, arm64, 386, arm — architecture
  • cgo — built with CGO_ENABLED=1
  • race — built with -race
  • Any build constraint you pass to go build -tags foo

The basic use case is platform-specific code:

// file: timer_linux.go
//go:build linux

package timer

func HighResolutionTicks() int64 {
    // Linux-specific clock_gettime CLOCK_MONOTONIC_RAW
}
// file: timer_darwin.go
//go:build darwin

package timer

func HighResolutionTicks() int64 {
    // mach_absolute_time
}

Go also has a filename convention — _linux.go, _darwin.go, etc. — that’s equivalent to a build tag. You can combine both for more specific cases.

Where build tags get interesting is for opt-in features. On a previous job, we had a debug-only feature that added heavy instrumentation:

// file: trace.go
//go:build !tracing

package myservice

func trace(_ string, _ ...any) {}
// file: trace_on.go
//go:build tracing

package myservice

import "log"

func trace(msg string, args ...any) {
    log.Printf("TRACE: "+msg, args...)
}

Normal builds get the no-op version, which the compiler can inline and eliminate entirely. Developers who want tracing build with go build -tags tracing. No runtime cost for production.

This is a much cleaner pattern than:

// BAD
var traceEnabled = false

func trace(msg string, args ...any) {
    if !traceEnabled {
        return
    }
    log.Printf("TRACE: "+msg, args...)
}

Because even with that runtime check, the call to trace() still evaluates its arguments. If args are expensive to construct, you pay the cost. With build tags, the non-tracing version’s body is empty and the compiler can inline to nothing.

A similar pattern for benchmarks: conditional “unsafe fast paths”:

//go:build unsafe

package hash

// uses unsafe.Pointer casts for speed
//go:build !unsafe

package hash

// safe fallback

Or for test-only helpers:

//go:build testutil

// only built when importing this package from test code built with -tags testutil

A useful one I stumbled onto: gating integration tests behind a tag so they don’t run on every go test.

// file: integration_test.go
//go:build integration

package myservice_test

func TestDatabaseIntegration(t *testing.T) {
    // connects to a real Postgres
}

go test ./... skips this file. go test -tags integration ./... includes it. CI runs both, dev runs only the fast ones by default. Much nicer than t.Skip() with environment variable checks.

You can build more complex expressions:

//go:build (linux && amd64) || (darwin && arm64)

Or use the go tag for compiler-version-specific code:

//go:build go1.21

A few less-obvious things:

Build tags aren’t macros. You can’t conditionally include a line of code in a file. The granularity is file. If you want conditional logic within a file, use a runtime if.

Empty stubs are necessary. If file A is linux-only and has func Foo(), you need a Foo in non-linux builds too, or callers won’t compile. Typically this is a stub in foo_other.go or foo_fallback.go that returns an error or does nothing.

go vet runs per build configuration. If you have a file gated with //go:build windows, go vet won’t check it on Linux. You need to vet on every target platform:

GOOS=windows go vet ./...
GOOS=darwin go vet ./...

Build tag expressions don’t see custom OS/arch combinations without -tags. If you invent a tag myfeature, it’s only active when you pass -tags myfeature. There’s no default.

A case I ran into recently: an optional dependency for metrics export. We have a Prometheus backend and a Datadog backend. Customers pick one. Our main binary shouldn’t link both (each has a nontrivial amount of code). Build tags solve this cleanly:

//go:build prometheus

package metrics
// uses github.com/prometheus/client_golang
//go:build datadog

package metrics
// uses github.com/DataDog/datadog-go

Two different binaries from the same source tree, each with only the dependency it needs.

Build tags are one of those features that looks niche until you start reaching for them. They’re not a macro system — Go deliberately doesn’t have one — but for the 80% of cases where you want conditional compilation, they’re exactly enough.

Related: I wrote about cargo workspace inheritance, which is a different tool for a different problem (per-crate dependency management), but similar in spirit — keep builds deterministic and declarative.