How to Use Build Constraints
You're building a command-line tool that needs to read system metrics. On Linux, you read from /proc/stat. On macOS, you call sysctl. On Windows, you need WMI. You write three different functions. Now you need a way to tell the compiler which one to use without littering your code with if runtime.GOOS == "linux" checks everywhere. That's where build constraints come in. They let you split your code into files that only compile under specific conditions.
Build constraints act like filters on your source files. The Go compiler scans every .go file in a package. If a file has a constraint that doesn't match the current build environment, the compiler skips it entirely. It's as if the file doesn't exist. This lets you write platform-specific code, toggle debug features, or swap implementations without runtime overhead. The binary contains only the code that matches the target environment.
Minimal example
Here's the simplest constraint: include a file only when building for Linux.
//go:build linux
package main
// GetMetrics returns data from /proc/stat.
// This file is ignored when building for darwin or windows.
func GetMetrics() string {
return "linux-metrics"
}
The directive //go:build linux tells the compiler to include this file only if the target OS is Linux. If you run go build on a Mac, the compiler skips this file. If you run GOOS=linux go build, the compiler includes it. The function GetMetrics must be defined somewhere else for other platforms, or the build will fail with an undefined symbol error.
The compiler skips the file. No runtime check needed. No dead code in the binary.
How constraints work
The directive must appear at the top of the file, before the package statement. You can have comments and blank lines before it, but no code. The compiler reads the constraint and evaluates it against the build environment. The environment includes the OS, architecture, and any custom tags you pass with -tags.
The constraint language supports boolean operators. You can negate a condition with !. You can combine conditions with && and ||. Spaces separate AND conditions. Newlines separate OR conditions.
//go:build linux && (amd64 || arm64)
package main
// OptimizeFor64Bit enables vector instructions.
// Only active on 64-bit Linux systems.
func OptimizeFor64Bit() {}
This constraint includes the file on Linux for both 64-bit architectures. The parentheses group the OR logic. The compiler evaluates the expression and decides whether to include the file.
You can also use the ignore keyword to exclude a file from all builds. This is useful for template files or files meant for documentation only.
//go:build ignore
package main
// This file is never compiled.
// It might contain a template for generating code.
The ignore constraint is a shorthand for a condition that is always false. The compiler skips the file regardless of the environment.
gofmt knows about build constraints. It keeps the directive at the top and formats it correctly. You don't need to worry about indentation. The directive is always flush left. Most editors run gofmt on save, so the formatting stays consistent.
Realistic example: custom tags
Custom tags let you define your own switches. This is common for enabling verbose logging, swapping a heavy dependency for a mock, or toggling experimental features. You pass custom tags to the compiler with the -tags flag.
//go:build debug
package main
// LogVerbose prints detailed trace information.
// Only compiled when the build includes the "debug" tag.
func LogVerbose(msg string) {
fmt.Println("[DEBUG]", msg)
}
To include files with custom tags, you pass the -tags flag to the compiler.
# Build with debug features enabled
go build -tags debug ./cmd/myapp
# Build without debug features; the debug file is skipped
go build ./cmd/myapp
The first command includes the debug file. The second command skips it. This lets you ship a production binary without the debug code. The debug functions are not in the binary at all. You save space and avoid accidental verbose output.
Custom tags are also useful for integration tests. You might have tests that connect to a real database, which you only want to run in a CI pipeline, not during local development.
//go:build integration
package db_test
// TestRealConnection verifies the database connection.
// Skipped unless the build includes the "integration" tag.
func TestRealConnection(t *testing.T) {
// connect to real DB
}
You run these tests with go test -tags integration. Without the tag, the test file is skipped. This keeps your unit tests fast and isolated.
One function signature, multiple implementations. The compiler picks the right one. Your callers never know the difference.
Cross-compilation
Build constraints shine during cross-compilation. You can build a binary for Windows while sitting on a Mac. The compiler uses the constraints to pick the right files. You don't need a Windows machine. You just set GOOS=windows and GOARCH=amd64. The compiler assembles the package using the Windows-constrained files and the shared files.
# Build a Windows binary on macOS
GOOS=windows GOARCH=amd64 go build -o myapp.exe ./cmd/myapp
The compiler respects the constraints and includes only the files that match the target environment. This makes it easy to support multiple platforms from a single development machine.
Tools like go doc and go vet also respect build constraints. If a file is excluded, the documentation won't show the symbols from that file. This keeps the public API clean. You can force inclusion by setting GOOS and GOARCH environment variables.
# View documentation for Linux-specific symbols
GOOS=linux go doc ./pkg/sensor
This lets you inspect platform-specific code even if you're on a different platform.
Pitfalls and errors
Build constraints are powerful, but they can trip you up if you're not careful.
The new syntax uses spaces for AND and newlines for OR. Commas have no special meaning in //go:build. If you write //go:build linux,amd64, the compiler treats linux,amd64 as a single tag name, which probably doesn't exist. You get a file that is never included. The compiler won't error on a bad constraint; it just skips the file. The error shows up later as a missing symbol.
The compiler rejects the program with
undefined: GetMetricsif the constraint excludes the file that defines the function.
This is the most common mistake. You write a constraint that doesn't match, the file is skipped, and you get an undefined error. The fix is to check the constraint logic. Make sure the tags and environment variables match what you expect.
Another pitfall is putting the constraint in the wrong place. The directive must be at the top of the file, before the package statement. If you put it after the package, the compiler ignores it and treats it as a regular comment.
The compiler complains with
//go:build must appear before the package clauseif the directive is misplaced.
You might also encounter // +build in older codebases. That is the legacy syntax from before Go 1.17. The //go:build syntax is the current standard. It supports boolean operators and is easier to read. The compiler supports both for backward compatibility. If a file has both, they must agree. If they disagree, the build fails.
The compiler fails with
//go:build and +build directives disagreeif the two constraints conflict.
New code should always use //go:build. The legacy syntax is harder to read and doesn't support boolean operators.
A bad constraint doesn't crash the build. It hides your code. The error appears as a missing function, not a syntax error.
Decision matrix
Use //go:build when you need platform-specific implementations that must compile cleanly on all targets.
Use runtime checks like runtime.GOOS when the logic difference is small and fits inside a single function.
Use -tags with build constraints when you want to toggle features like debug mode or optional dependencies without changing the source code.
Use separate packages when the platform-specific code is large and introduces external dependencies that only exist on certain systems.
Use //go:build ignore when you want to exclude a file from all builds, such as a template file or a file meant for documentation only.
Trust the constraint logic. Test your builds on every target platform. A missing constraint is a bug that only shows up when someone tries to build on a different machine.