Build Tags and Conditional Compilation Advanced¶
Introduction¶
Build tags (build constraints) let you include or exclude Go source files from compilation based on target platform, architecture, custom flags, or other conditions. This is Go's mechanism for conditional compilation — there is no preprocessor or #ifdef. Instead, the build system selects which files to compile based on constraints declared at the top of each file or encoded in the filename.
Build tags are essential for platform-specific code (syscalls, file paths), separating integration tests from unit tests, feature flags, and debug/release builds. Since Go 1.17, the //go:build syntax replaced the older // +build format with clearer boolean logic.
Syntax & Usage¶
The //go:build Directive (Go 1.17+)¶
The directive must appear before the package clause, and a blank line must separate it from the package statement. It uses standard boolean syntax:
//go:build linux // Linux only
//go:build linux || darwin // Linux OR macOS
//go:build linux && amd64 // Linux AND 64-bit x86
//go:build !windows // anything except Windows
//go:build (linux || darwin) && amd64 // (Linux OR macOS) AND 64-bit
//go:build ignore // never compiled (useful for examples, generators)
Old // +build Syntax (Pre-Go 1.17)¶
Still recognized but deprecated. If you maintain legacy code:
| Old syntax | New equivalent | Logic |
|---|---|---|
// +build linux darwin |
//go:build linux \|\| darwin |
OR (space = OR on same line) |
Two // +build lines |
//go:build A && B |
AND (separate lines = AND) |
// +build !windows |
//go:build !windows |
NOT |
// +build linux,amd64 |
//go:build linux && amd64 |
AND (comma = AND) |
Run gofmt to auto-add the new //go:build line alongside existing // +build lines for compatibility.
File Naming Conventions¶
Go also uses filename suffixes as implicit build constraints — no directive needed:
| Filename Pattern | Constraint Applied |
|---|---|
file_linux.go |
GOOS=linux |
file_windows.go |
GOOS=windows |
file_darwin.go |
GOOS=darwin |
file_amd64.go |
GOARCH=amd64 |
file_arm64.go |
GOARCH=arm64 |
file_linux_amd64.go |
GOOS=linux && GOARCH=amd64 |
file_test.go |
Only compiled during go test |
mypackage/
├── path.go # shared code
├── path_linux.go # Linux implementation
├── path_windows.go # Windows implementation
└── path_darwin.go # macOS implementation
Common Built-in Tags¶
| Tag | Source | Values |
|---|---|---|
GOOS |
Target OS | linux, darwin, windows, freebsd, js, wasip1 |
GOARCH |
Target arch | amd64, arm64, 386, arm, wasm |
cgo |
CGo enabled | Present when CGO_ENABLED=1 |
ignore |
Special | Excludes file from all builds |
go1.21 |
Go version | Present when Go version >= 1.21 |
Platform-Specific Code¶
A practical example — opening a browser across platforms:
// open_linux.go
//go:build linux
package browser
import "os/exec"
func Open(url string) error {
return exec.Command("xdg-open", url).Start()
}
// open_darwin.go
//go:build darwin
package browser
import "os/exec"
func Open(url string) error {
return exec.Command("open", url).Start()
}
// open_windows.go
//go:build windows
package browser
import "os/exec"
func Open(url string) error {
return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
}
Custom Build Tags¶
Define your own tags and pass them at build time:
// feature_analytics.go
//go:build analytics
package app
func init() {
registerAnalyticsMiddleware()
}
// feature_analytics_stub.go
//go:build !analytics
package app
// No analytics — this file provides no-op or is empty
# Build with analytics enabled
go build -tags analytics ./...
# Build without analytics (default — no tag needed)
go build ./...
# Multiple custom tags
go build -tags "analytics,debug" ./...
Integration Test Separation¶
A common pattern to separate slow integration tests from fast unit tests:
// user_integration_test.go
//go:build integration
package user_test
import "testing"
func TestUserCreationIntegration(t *testing.T) {
db := connectToTestDB(t)
defer db.Close()
repo := NewPostgresUserRepo(db)
user, err := repo.Create(context.Background(), &User{Name: "Alice"})
if err != nil {
t.Fatalf("creating user: %v", err)
}
fetched, err := repo.GetByID(context.Background(), user.ID)
if err != nil {
t.Fatalf("fetching user: %v", err)
}
if fetched.Name != "Alice" {
t.Errorf("got name %q, want %q", fetched.Name, "Alice")
}
}
# Run only unit tests (fast, no external dependencies)
go test ./...
# Run integration tests too
go test -tags integration ./...
Debug/Release Builds¶
// debug.go
//go:build debug
package app
import "log"
func debugLog(format string, args ...any) {
log.Printf("[DEBUG] "+format, args...)
}
const isDebug = true
// release.go
//go:build !debug
package app
func debugLog(format string, args ...any) {}
const isDebug = false
Combining Build Tags and File Naming¶
You can use both — the constraints are ANDed together:
// netpoll_linux_amd64.go
//go:build !cgo
package net
// This file is compiled only when:
// GOOS=linux AND GOARCH=amd64 (from filename)
// AND CGo is disabled (from directive)
Quick Reference¶
| Syntax | Meaning |
|---|---|
//go:build linux |
Linux only |
//go:build linux \|\| darwin |
Linux OR macOS |
//go:build linux && amd64 |
Linux AND amd64 |
//go:build !windows |
NOT Windows |
//go:build ignore |
Never compiled |
//go:build cgo |
CGo enabled |
//go:build go1.21 |
Go 1.21 or later |
_linux.go suffix |
Implicit GOOS=linux |
_test.go suffix |
Test files only |
-tags "foo,bar" |
Pass custom tags at build time |
Best Practices¶
- Use
//go:build(not// +build) — the new syntax is clearer, supports proper boolean expressions, and is enforced bygofmtsince Go 1.17. - Prefer file naming for platform code —
_linux.go,_darwin.gosuffixes are self-documenting and don't require opening the file to understand the constraint. - Pair constrained files — if
feature_linux.goprovides a function, ensurefeature_other.go(with//go:build !linux) provides a stub or alternative. A missing implementation causes compile errors only on affected platforms. - Use
//go:build integrationfor slow tests — keepsgo test ./...fast for developers while CI runsgo test -tags integration ./.... - Keep custom tags minimal — every tag is a build matrix dimension. Limit to well-defined use cases (integration tests, feature flags, debug builds).
- Document custom tags in your README — developers need to know which tags exist and what they enable.
Common Pitfalls¶
Missing stub file for constrained code
If there's noserver_other.go with //go:build !linux, building on macOS or Windows fails:
Always provide a matching file for other platforms — even if it just returns an error.
Blank line between directive and package
//go:build linux
package mypackage // BUG: no blank line — directive is ignored!
// CORRECT:
//go:build linux
package mypackage
//go:build directive requires a blank line between it and the package clause. Without it, the constraint is silently ignored.
Forgetting -tags in CI
If you use custom tags for integration tests, your CI must pass them:Tag typos are silent
//go:build linus // typo: "linus" instead of "linux"
package mypackage
// This file is NEVER compiled — no error, no warning
go vet and review constraints carefully.
Performance Considerations¶
- Zero runtime cost: Build tags are resolved at compile time. Excluded files are never compiled, so there's no runtime overhead for conditional compilation — unlike runtime feature flags with
ifchecks. - Binary size: Only files matching the build constraints are compiled into the binary. Platform-specific code for other OSes is completely absent.
- Build cache: Different tag combinations produce different cache entries. Using many custom tags can reduce cache hit rates and slow incremental builds.
- Compile time: Build tags themselves add negligible compile time. But having many platform-specific files in a package means the build system must evaluate constraints for each file.
Interview Tips¶
Interview Tip
"How does Go handle conditional compilation?" Go uses build constraints — //go:build directives and filename suffixes (_linux.go, _amd64.go). There's no preprocessor. Files that don't match the current GOOS, GOARCH, or custom tags are completely excluded from compilation. This keeps the build clean — no dead code in the binary.
Interview Tip
"How would you separate integration tests from unit tests?" Use a custom build tag: add //go:build integration to integration test files. Running go test ./... skips them (fast developer loop). CI runs go test -tags integration ./... to include them. This avoids separate test directories and keeps tests near the code they test.
Interview Tip
"What's the difference between //go:build and // +build?" //go:build (Go 1.17+) uses standard boolean operators (&&, ||, !, parentheses). The old // +build used commas for AND and spaces for OR — confusing and error-prone. gofmt automatically adds the new syntax when it sees the old one. New code should only use //go:build.
Interview Tip
"How do you write platform-specific code in Go?" Two approaches, often combined: (1) Filename suffixes — dns_linux.go, dns_windows.go provide platform-specific implementations of the same functions. (2) //go:build directives for more complex constraints like linux && amd64 && cgo. Always provide a fallback file (//go:build !linux) to avoid compile errors on unsupported platforms.
Key Takeaways¶
//go:build(Go 1.17+) is the standard syntax for build constraints — uses&&,||,!, and parentheses.- Filename suffixes (
_linux.go,_amd64.go,_test.go) provide implicit constraints without directives. - Custom tags (
-tags integration) separate integration tests, enable feature flags, and support debug/release builds. - Build tags are resolved at compile time — zero runtime cost, no dead code in the binary.
- Always provide matching files for all platforms — a missing stub causes compile errors only on the affected OS.
- Tag typos are silent — the file is simply never compiled. Review constraints carefully and use
go vet. - A blank line is required between
//go:buildandpackage— without it the constraint is silently ignored.