- Book: The Complete Guide to Go Programming
- Also by me: Hexagonal Architecture in Go — the companion book in the Thinking in Go series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You add a Linux-only feature to a Go service. It builds on your
machine, tests pass, CI is green. Then a colleague on a Mac runs
go build ./... and gets an undefined-symbol error for a function
that clearly exists in the tree. The function is there. The compiler
just refused to look at the file that defines it.
That is a build constraint doing its job. Go decides, before it
parses a single expression, whether a file belongs in the current
build at all. Get the rules right and platform-specific code and
integration tests stay out of each other's way. Get them wrong and
you ship a package that compiles on exactly one machine.
Two ways Go excludes a file
Go has two mechanisms for conditional compilation, and they run at
the same stage: file selection, before type checking.
The first is the filename. A file named cache_linux.go compiles
only when GOOS=linux. A file named cache_amd64.go compiles only
when GOARCH=amd64. You can combine them: cache_linux_amd64.go
needs both. The go tool reads these suffixes directly, so the
constraint is invisible inside the file.
The second is the //go:build comment at the top of the file. This
is the explicit form, and it handles everything the filename can't:
custom tags, boolean logic, Go version gates.
//go:build linux && amd64
package cache
Both mechanisms combine with AND. A file called cache_linux.go
that also carries //go:build amd64 compiles only on
linux/amd64. If the two disagree, nothing compiles, which is a
common way to accidentally exclude a whole file.
The //go:build syntax
The constraint is a boolean expression over build tags. The
operators are &&, ||, !, and parentheses. Nothing else.
//go:build (linux || darwin) && !cgo
That file compiles on Linux or macOS, but only when cgo is off.
The comment has strict placement rules. It goes near the top of the
file, and it must be followed by a blank line before the package
clause. If any non-comment, non-blank line comes first, Go treats
the //go:build line as an ordinary comment and ignores it. No
error, no warning. The file just compiles everywhere, which is
rarely what you meant.
//go:build integration
package store
The blank line between the constraint and package store is not
cosmetic. It is what tells the tool the comment is a build
constraint and not a doc comment attached to the package.
Several build tags are always defined by the toolchain: the current
GOOS, the current GOARCH, cgo (set when cgo is enabled), and
unix (set on any Unix-like GOOS). There are also goVERSION tags
like go1.23 that match every Go release at or above that version.
Everything else is a tag you define yourself.
GOOS and GOARCH files
For platform splits, prefer the filename suffix over the comment. It
reads at a glance in a directory listing and never gets the blank-line
rule wrong.
Say you need a per-platform config path. Three files, one shared
signature:
// config_linux.go
package config
func defaultDir() string {
return "/etc/myapp"
}
// config_darwin.go
package config
func defaultDir() string {
return "/Library/Application Support/myapp"
}
// config_windows.go
package config
func defaultDir() string {
return `C:\ProgramData\myapp`
}
The rest of the package calls defaultDir() with no build tags in
sight. Exactly one definition compiles per target, so there is no
duplicate-symbol conflict and no runtime.GOOS switch scattered
through the code. If you build for a platform with no matching file,
you get a compile error for the missing function, which is the
signal you forgot a platform.
The suffix has to be a real, recognized GOOS or GOARCH. A file named
config_osx.go has no special meaning because osx is not a valid
GOOS. It compiles everywhere, and now you have two defaultDir
functions on Darwin. The correct token is darwin.
Custom tags for integration tests
The most useful everyday case has nothing to do with platforms. You
have a test that needs a real Postgres, or a live network, or thirty
seconds you don't want to spend on every go test ./.... Gate it
behind a custom tag.
//go:build integration
package store
import "testing"
func TestPostgresRoundTrip(t *testing.T) {
db := connectRealPostgres(t)
// ... real queries against a real database
}
By default this file is excluded. go test ./... skips it and runs
fast. When you want the heavy tests, opt in:
go test -tags=integration ./...
The tag name is arbitrary. Pick something your team recognizes:
integration, e2e, slow. Pass multiple tags as a
comma-separated list, -tags=integration,e2e, and they are all
defined for that build.
One trap worth stating plainly: a tagged file is invisible to the
compiler when the tag is off, so anything only that file references
looks unused. If connectRealPostgres lives in a non-tagged file
and nothing else calls it, go vet and your linter will flag it as
dead code in the default build. Keep the helper in the same tagged
file, or behind the same tag, so it appears and disappears with its
caller.
You can also invert the gate. A file that should compile in
every build except the integration one:
//go:build !integration
That pattern is handy for a stub or in-memory fake that stands in
for the real dependency during fast tests.
The old +build syntax
Before Go 1.17, constraints looked different:
// +build linux,amd64
package cache
That is the legacy form. The comma means AND, spaces mean OR, and
the whole thing sat behind a // +build prefix that was easy to
typo. It also demanded the same trailing blank line, and the leading
space after // mattered. The syntax was error-prone enough that
the Go team replaced it.
Go 1.17 introduced //go:build with real boolean operators and made
gofmt maintain both forms in sync during the transition. For a
few releases, a file could carry both:
//go:build linux && amd64
// +build linux,amd64
package cache
gofmt kept the // +build line matching the //go:build line
automatically, so older toolchains still understood the file. That
transition window is over. Any Go version you would run today
(1.23, 1.24, and up) reads //go:build natively, so the +build
line is pure noise.
To strip the legacy lines across a codebase, let gofmt do it. Any
modern gofmt removes the redundant // +build line when it sees a
matching //go:build:
gofmt -w .
Run that, review the diff, and the +build comments are gone. If
you still see +build lines afterward, they didn't have a matching
//go:build line above them, which means someone hand-wrote a
legacy constraint that a modern toolchain is quietly ignoring.
That is a file compiling on every platform when it shouldn't be, and
it is worth grepping for:
grep -rn '// +build' --include='*.go' .
Every hit is either a missing //go:build twin or a constraint that
stopped doing anything. Convert them by hand, add the blank line,
and let gofmt confirm.
How to check what actually compiles
The constraint logic is easy to reason about wrong. Two commands
tell you the truth instead of your guess.
To list the files the tool selects for a given target:
go list -f '{{.GoFiles}}' ./config
GOOS=windows go list -f '{{.GoFiles}}' ./config
The first shows the files for your host platform. The second shows
what Windows would get. Compare them and you can see exactly which
suffix or tag pulled each file in or left it out. Add -tags:
go list -tags=integration -f '{{.GoFiles}}' ./store
Now the integration file appears in the list. When a file mysteriously
won't compile, or compiles when you expected it not to, go list is
the fastest way to confirm which constraint fired before you go
hunting through boolean expressions.
The rules worth memorizing
Build constraints are a small feature with a few sharp edges. Four
things keep you out of trouble:
- Filename suffixes and
//go:buildboth AND together. If a file has both, both must pass. - The blank line after
//go:buildis mandatory. Without it, the constraint is a plain comment and the file compiles everywhere. - GOOS/GOARCH suffixes must be real tokens.
darwin, notosx;amd64, notx64. A typo silently disables the constraint. -
// +buildis dead. Rungofmt -w ., grep for stragglers, and convert anything that survives.
None of these produce loud failures. A broken constraint compiles a
file you meant to exclude, or excludes one you meant to keep, and
you find out on the one platform you didn't test. go list is how
you see the selection the compiler sees, before it bites.
Build constraints sit at the boundary between your code and the
platforms it runs on, which makes them a good example of a decision
better made once, at the edge, than scattered through the logic.
The Complete Guide to Go Programming digs into how the toolchain
selects and compiles files, tags included, if you want the runtime-
and toolchain-level picture. Hexagonal Architecture in Go is the
one to read for keeping platform and test seams at the right
boundary so the constraints stay few and legible.

Top comments (0)