Build tags in Go
You write a function that calls a Linux-specific system call. It works perfectly on your machine. You push the code to the repository. The continuous integration runner on Windows fails instantly. The error message screams undefined: syscall.X. You didn't break the logic. You broke portability. The code exists, but the target platform doesn't know what to do with it.
Go solves this with build tags. Build tags are special comments that tell the compiler whether to include a file in the build. They act as compile-time switches. If the conditions match, the file is compiled. If they don't, the compiler ignores the file entirely. This lets you ship platform-specific code, toggle features, or swap implementations without cluttering your main logic with if statements.
How build tags work
A build tag is a boolean expression attached to a Go source file. The compiler reads the tag before it parses the code. It evaluates the expression against the current build environment. The environment includes variables like GOOS (the operating system), GOARCH (the architecture), and any custom tags you pass on the command line.
Think of your project as a factory floor. Each file is a part in a bin. The bins are labeled with conditions. When you order a build for Linux, the assembler walks the floor and grabs every part from bins labeled "Linux". It ignores the bins labeled "Windows" or "Test". The assembler never opens the ignored bins, so it doesn't care if the parts inside reference tools that don't exist on the factory floor.
This early filtering is powerful. It means you can write code that uses types, functions, or packages that only exist on specific platforms. The compiler never sees that code when building for a different platform, so it never complains about undefined symbols.
Minimal example
The modern syntax uses //go:build. This syntax became the standard in Go 1.17. Place the tag at the very top of the file, before the package clause.
//go:build linux // Include this file only when GOOS is linux
package main // Package declaration must follow immediately
// GetPlatform returns the OS identifier.
func GetPlatform() string {
return "Linux" // Return value specific to this platform
}
If you build this file on a Mac, the compiler skips it. The function GetPlatform does not exist in the resulting binary. You need a fallback for other platforms.
//go:build darwin // Include this file only when GOOS is darwin
package main // Package declaration follows the tag
// GetPlatform returns the OS identifier.
func GetPlatform() string {
return "macOS" // Return value for macOS
}
Now the project compiles on both platforms. On Linux, the first file is included. On macOS, the second file is included. The function signature matches, so the rest of your code works unchanged.
Build tags are compile-time filters. They disappear from the binary.
Realistic example: Platform-specific configuration
A common pattern is loading configuration from different locations depending on the OS. Unix systems often use /etc/app.conf. Windows uses %APPDATA%. You can split these implementations into separate files.
Create config.go with the interface.
package config // Shared package for configuration
// Loader defines how to read configuration.
type Loader interface {
Load() (string, error) // Load returns the config content
}
Create config_unix.go for Linux and macOS.
//go:build linux || darwin // Include on Linux or macOS
package config // Package declaration follows tag
import "os" // Import OS package for file reading
// UnixLoader reads from /etc/app.conf.
type UnixLoader struct{} // Struct holds no state
// Load reads the configuration file.
func (l UnixLoader) Load() (string, error) {
data, err := os.ReadFile("/etc/app.conf") // Read standard Unix path
if err != nil {
return "", err // Propagate read errors
}
return string(data), nil // Return content as string
}
Create config_windows.go for Windows.
//go:build windows // Include only on Windows
package config // Package declaration follows tag
import "os" // Import OS package
// WindowsLoader reads from the user's AppData directory.
type WindowsLoader struct{} // Struct holds no state
// Load reads the configuration file.
func (l WindowsLoader) Load() (string, error) {
path := os.Getenv("APPDATA") + "\\app.conf" // Construct Windows path
data, err := os.ReadFile(path) // Read file
if err != nil {
return "", err // Propagate errors
}
return string(data), nil // Return content
}
The compiler picks the right loader based on the target OS. Your application code just calls config.Loader.Load(). It never needs to know which file provided the implementation.
Keep build tags close to the package clause. The compiler is strict about placement.
Custom build tags and feature flags
Build tags aren't limited to OS and architecture. You can define custom tags to toggle features. This is useful for enterprise features, debug modes, or experimental code that shouldn't ship in production.
Create a file with a custom tag.
//go:build debug // Include only when the debug tag is set
package main // Package declaration follows tag
// DebugLog prints verbose output.
func DebugLog(msg string) {
println("DEBUG:", msg) // Print to stdout
}
This file is excluded by default. To include it, pass the tag to the build command.
go build -tags=debug .
The -tags flag adds debug to the list of active tags. The compiler now includes the file. You can combine tags.
go build -tags="debug,enterprise" .
This activates both debug and enterprise tags. Files with //go:build enterprise are also included.
Custom tags are powerful but require discipline. If you forget to pass the tag, the feature vanishes silently. Document your tags. List them in your README. Automate the build process so developers don't have to remember the flags.
Test your tags. A file excluded by a tag is a file that never runs.
Testing with build tags
Tests respect build tags. If a test file has //go:build linux, it won't run on Windows. This is usually what you want. You don't want Linux-specific tests failing on a Windows runner.
Sometimes you need to force a tag for testing. Use the -tags flag with go test.
go test -tags=debug ./...
This runs all tests, including those gated by the debug tag.
A common pattern is using build tags to swap implementations for tests. You can create a mock implementation that is only included when a test tag is active.
//go:build test // Include only when testing with -tags=test
package config // Package declaration follows tag
// MockLoader returns hardcoded data.
type MockLoader struct{} // Struct for mocking
// Load returns fake configuration.
func (l MockLoader) Load() (string, error) {
return "mocked_config", nil // Return deterministic data
}
In your test file, use the mock.
//go:build test // Ensure this test file also requires the tag
package config_test // Test package
import (
"testing"
"yourmodule/config"
)
func TestMockLoader(t *testing.T) {
loader := config.MockLoader{} // Use mock implementation
data, err := loader.Load()
if err != nil {
t.Fatal(err) // Fail on error
}
if data != "mocked_config" {
t.Errorf("unexpected data: %s", data) // Check result
}
}
Run the test with the tag.
go test -tags=test ./config
This pattern keeps mocks out of production builds. The mock code is excluded unless you explicitly request it.
Pitfalls and errors
Build tags have strict rules. Violating them causes build failures.
Tag placement
The //go:build line must be the first line of the file, or preceded only by blank lines and comments. It must be immediately followed by the package clause. No other code can sit between the tag and the package.
If you put the tag after the package, the compiler ignores it. You get undefined errors because the file is always included.
If you put code between the tag and the package, the compiler rejects the file with //go:build syntax error.
Missing files
If all files in a directory are excluded by build tags, the build fails. The compiler reports build constraints exclude all Go files in directory. Ensure at least one file in each package is always included, or structure your packages so that empty directories are acceptable.
Legacy syntax
Before Go 1.17, build tags used the // +build syntax. This syntax is deprecated. Go 1.17 introduced //go:build as the unified standard.
You can keep both for compatibility with older toolchains.
//go:build linux && amd64 // Modern syntax
// +build linux,amd64 // Legacy syntax for Go < 1.17
package main // Package declaration follows tags
The compiler uses //go:build as the source of truth. The legacy line is ignored if the modern line exists. If only the legacy line exists, the compiler parses it for backward compatibility. New code should use only //go:build.
Complex expressions
Build tags support &&, ||, !, and parentheses.
//go:build linux && amd64 // Linux on 64-bit AMD
//go:build darwin || freebsd // macOS or FreeBSD
//go:build !windows // Everything except Windows
Complex expressions can hide bugs. If you write //go:build linux && amd64 || darwin, the precedence matters. The compiler evaluates && before ||. This is equivalent to (linux && amd64) || darwin. Use parentheses to make your intent clear.
//go:build (linux && amd64) || darwin // Explicit grouping
File naming conventions
Old Go code used file names like foo_linux.go to imply build tags. This naming convention is still supported but deprecated. Comments are the preferred method. If a file has both a naming tag and a comment tag, the comment takes precedence. Rely on comments. They are explicit and support complex logic that file names cannot express.
The compiler warns about naming conventions in newer versions. Migrate to comments to keep your codebase clean.
Build tags are not runtime flags. They vanish after compilation.
Decision matrix
Use build tags when you have platform-specific code that cannot be abstracted into a single implementation. Use build tags when you need to toggle expensive debug logging or enterprise features at compile time. Use build tags when you want to swap test mocks without polluting production binaries. Reach for interfaces and dependency injection when the variation is behavioral and testable, not tied to the OS or architecture. Pick environment variables when the configuration changes per deployment, not per platform. Use separate packages when the implementations are large, distinct, and rarely shared.
Build tags are compile-time switches. They are not magic.