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.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.