Go Build Tags: Conditional Compilation
• Build tags enable conditional compilation in Go, allowing you to include or exclude code based on operating system, architecture, or custom conditions without runtime overhead
Key Insights
• Build tags enable conditional compilation in Go, allowing you to include or exclude code based on operating system, architecture, or custom conditions without runtime overhead
• The //go:build directive uses boolean logic (AND, OR, NOT) and must appear before the package clause, replacing the legacy // +build syntax
• Custom build tags are powerful for feature flags, environment-specific code, and separating integration tests from unit tests
Introduction to Build Tags
Go’s build tags provide compile-time conditional compilation, letting you include or exclude entire files from your build based on specified conditions. Unlike preprocessor directives in C or C++, Go’s approach is file-based: files with unsatisfied build constraints are completely ignored by the compiler.
Build tags solve several practical problems. You can write platform-specific implementations without littering your code with runtime checks. You can toggle expensive features on and off. You can separate integration tests that require external dependencies from fast unit tests. All of this happens at compile time, meaning zero runtime performance cost.
Here’s the simplest example—a file that only compiles on Linux:
//go:build linux
package myapp
import "fmt"
func PlatformName() string {
return fmt.Sprintf("Running on Linux")
}
When you run go build on a Linux machine, this file is included. On Windows or macOS, it’s completely ignored as if it doesn’t exist.
Build Tag Syntax and Basics
The //go:build directive must appear at the top of your Go file, before the package declaration, separated by a blank line. The syntax supports boolean logic:
- AND: Use
&&or spaces - OR: Use
|| - NOT: Use
! - Grouping: Use parentheses
//go:build linux && amd64
package myapp
This file only compiles on 64-bit Linux systems. You can combine multiple conditions:
//go:build (linux || darwin) && cgo
package myapp
This compiles on Linux or macOS, but only when cgo is enabled.
The legacy // +build syntax still works but is deprecated. It uses different operators and can be confusing:
// +build linux,amd64
// +build !windows
package myapp
In the old syntax, commas mean AND within a line, and multiple // +build lines mean OR between them. The new //go:build syntax is clearer and should be used for all new code. Run go fix to automatically convert old-style tags to new ones.
Go also uses filename conventions for common cases. A file named file_linux.go automatically gets the build constraint //go:build linux. Similarly, file_amd64.go gets //go:build amd64. You can combine them: file_linux_amd64.go means //go:build linux && amd64.
Common Build Tag Patterns
Platform-Specific Code
The most common use case is platform-specific implementations. Create separate files for each platform:
// storage_unix.go
//go:build unix
package storage
import "syscall"
func diskSpace(path string) (uint64, error) {
var stat syscall.Statfs_t
err := syscall.Statfs(path, &stat)
if err != nil {
return 0, err
}
return stat.Bavail * uint64(stat.Bsize), nil
}
// storage_windows.go
//go:build windows
package storage
import "golang.org/x/sys/windows"
func diskSpace(path string) (uint64, error) {
var freeBytes uint64
err := windows.GetDiskFreeSpaceEx(
windows.StringToUTF16Ptr(path),
&freeBytes,
nil,
nil,
)
return freeBytes, err
}
Both files define the same diskSpace function, but with platform-specific implementations. Your main code calls diskSpace() without caring about the platform.
Excluding Files from Builds
Use //go:build ignore to exclude files that shouldn’t be part of normal builds:
//go:build ignore
package main
// This is a code generation tool, not part of the main application
func main() {
// Generate code...
}
This is useful for code generators, one-off utilities, or example code in your repository.
Build Tag Constraints and Operations
Build constraints support complex logical expressions. Understanding operator precedence is crucial:
//go:build (linux && amd64) || (darwin && arm64) || (windows && !386)
package optimized
This file compiles on:
- 64-bit Linux
- ARM-based macOS (Apple Silicon)
- Windows, except 32-bit
You can negate entire expressions:
//go:build !(windows || plan9)
package unixonly
This compiles on everything except Windows and Plan 9.
For CGO-dependent code, you might write:
//go:build cgo && (linux || darwin)
package nativelib
// #cgo LDFLAGS: -lsomelib
// #include <somelib.h>
import "C"
func CallNative() {
C.some_function()
}
The cgo tag is automatically set when CGO is enabled (CGO_ENABLED=1).
Working with Custom Tags
Custom tags are where build tags become truly powerful. Define your own tags for features, environments, or testing scenarios.
Feature Flags
// analytics_premium.go
//go:build premium
package analytics
func TrackEvent(event string) {
// Send to premium analytics service
sendToSegment(event)
}
// analytics_basic.go
//go:build !premium
package analytics
func TrackEvent(event string) {
// Basic logging only
log.Println("Event:", event)
}
Build with the premium feature:
go build -tags=premium
Environment-Specific Configuration
// config_dev.go
//go:build dev
package config
const (
DatabaseURL = "localhost:5432"
Debug = true
)
// config_prod.go
//go:build prod
package config
const (
DatabaseURL = "prod-db.example.com:5432"
Debug = false
)
Build for production:
go build -tags=prod -o myapp
You can combine multiple custom tags:
go build -tags="premium,prod,analytics"
Build Tags in Practice
Separating Integration Tests
Integration tests often require databases, external APIs, or other infrastructure. Tag them separately:
//go:build integration
package myapp_test
import (
"testing"
"database/sql"
)
func TestDatabaseIntegration(t *testing.T) {
db, err := sql.Open("postgres", "...")
if err != nil {
t.Fatal(err)
}
defer db.Close()
// Test actual database operations
}
Run only unit tests (fast):
go test ./...
Run integration tests (slower, requires infrastructure):
go test -tags=integration ./...
Vendor-Specific Implementations
When supporting multiple backend providers:
// storage_s3.go
//go:build aws
package storage
import "github.com/aws/aws-sdk-go/service/s3"
type Storage struct {
client *s3.S3
}
// storage_gcs.go
//go:build gcp
package storage
import "cloud.google.com/go/storage"
type Storage struct {
client *storage.Client
}
Build for AWS:
go build -tags=aws
This keeps vendor SDKs out of builds where they’re not needed, reducing binary size and dependency complexity.
Build Scripts
A Makefile organizing different builds:
.PHONY: build-dev build-prod build-premium test test-integration
build-dev:
go build -tags=dev -o bin/app-dev
build-prod:
go build -tags=prod -ldflags="-s -w" -o bin/app-prod
build-premium:
go build -tags="prod,premium,analytics" -o bin/app-premium
test:
go test ./...
test-integration:
go test -tags=integration -v ./...
Best Practices and Gotchas
Project Organization
Structure your project to make build tags obvious:
myapp/
├── storage/
│ ├── storage.go # Common interface
│ ├── storage_unix.go # Unix implementation
│ ├── storage_windows.go # Windows implementation
│ └── storage_test.go
├── features/
│ ├── analytics.go # Common code
│ ├── analytics_premium.go # Premium features
│ └── analytics_basic.go # Basic features
└── integration_test/
└── db_test.go # //go:build integration
Common Mistakes
Don’t forget the blank line after the build tag:
// WRONG - will not work
//go:build linux
package myapp
// CORRECT
//go:build linux
package myapp
Remember that filename conventions and explicit tags combine with AND:
// file_linux.go
//go:build amd64
package myapp
This file requires BOTH linux (from filename) AND amd64 (from tag).
Testing Tagged Code
Test both paths when using build tags for logic:
# Test basic version
go test ./...
# Test premium version
go test -tags=premium ./...
Consider using interface-based designs so you can test the logic independently of platform-specific implementations.
Migration Strategy
If you have legacy // +build tags, run:
go fix ./...
This automatically converts to //go:build syntax. The tool is smart enough to handle complex cases correctly.
When NOT to Use Build Tags
Don’t use build tags for simple runtime configuration. If something can be a command-line flag or environment variable, that’s usually better. Build tags are for code that fundamentally cannot coexist in the same binary or requires different dependencies.
Build tags are a sharp tool. Use them for platform differences, expensive optional features, and test organization. They’ll keep your binaries lean and your builds fast.