How go:build expressions and filename suffixes decide which Go files compile, plus custom tags, cgo constraints, and testing across platforms.
· 7 min read

Go Build Constraints: //go:build Syntax, Filename Suffixes, and Custom Tags


A file named store_windows.go will never compile on Linux, and nothing inside the file says so. The go command also reads build constraints from filenames. That implicit rule sits alongside the explicit //go:build lines you write, and overlooking either one can lead to a “works on my machine, fails in CI” bug.

The rule itself is simple. A file joins the package only if its //go:build expression and its filename constraint are both satisfied for the current GOOS, GOARCH, and tag set. Get both right, keep a fallback file honest, and cross-platform builds stop ambushing you.

How //go:build expressions are evaluated

A build constraint is a line comment near the top of the file, before the package clause, with nothing above it but blank lines and other comments. It has to be followed by a blank line, or the toolchain reads it as package documentation.

//go:build (linux && 386) || (darwin && !cgo)

package platform

The expression uses ||, &&, !, and parentheses, with the same meaning they have in Go. One //go:build line per file. Two is an error.

The tags satisfied during a build are:

  • The target OS, as spelled by runtime.GOOS.
  • The target architecture, as spelled by runtime.GOARCH.
  • Architecture feature tags like amd64.v2 or arm.7.
  • unix, when GOOS is Unix-like.
  • The compiler: gc or gccgo.
  • cgo, when cgo is supported and enabled.
  • One tag per Go major release: go1.21, go1.22, and so on through the current version. Beta and minor releases get no tags.
  • Anything you pass via -tags.

The unix tag avoids enumerating every Unix-like target:

//go:build unix && !darwin

package fsutil

Three GOOS values also match another platform’s tags and files. GOOS=android matches linux, GOOS=illumos matches solaris, and GOOS=ios matches darwin. That means foo_darwin.go is included in an iOS build, which may be surprising if the file assumes it is running on macOS.

Filename suffixes are constraints you can’t see

Strip the extension and any _test suffix from a filename. If what’s left matches *_GOOS, *_GOARCH, or *_GOOS_GOARCH, the file carries an implicit constraint requiring those terms. dns_windows.go is Windows-only. math_386.s is 32-bit x86 only. source_windows_amd64.go needs both.

Two things bite people here.

The implicit constraint is additive, not a default. A file called cache_linux.go with //go:build darwin at the top compiles nowhere at all. The go command won’t warn you. It drops the file, and you get undefined: newCache on every platform, which sends you hunting in entirely the wrong direction.

The suffix only counts when the trailing word is a known OS or architecture value. handler_test.go is not constrained by test, but client_plan9.go is real Plan 9-only code.

When in doubt, ask the toolchain what it picked:

# Which files are in the package for this platform?
go list -f '{{.GoFiles}}' ./internal/platform

# And which are excluded?
GOOS=windows go list -f '{{.IgnoredGoFiles}}' ./internal/platform

IgnoredGoFiles is the field that answers the question. A file sitting there that you expected to compile means your constraint is wrong.

Keeping platform-specific implementations discoverable

A useful layout is one unconstrained file defining the API and constrained files providing the implementations. The shared file needs no build tag.

// notify.go — no build constraint, compiles everywhere.
package notify

// PlatformName returns the name used in this package's UI.
// Each constrained file provides platformName.
func PlatformName() string { return platformName() }
// notify_darwin.go
package notify

func platformName() string { return "macOS" }

If the package is meant to build on other platforms, it also needs a file that catches everything else. Without one, the first non-Darwin build fails with undefined: platformName:

// notify_fallback.go
//go:build !darwin

package notify

import "runtime"

func platformName() string { return runtime.GOOS }

Look at the filename: notify_fallback.go, not notify_other.go. Pick a descriptive suffix that is not a GOOS name, such as _fallback, _generic, or _stub.

The go command docs spell out this pairing for cgo. A file constrained with //go:build cgo && (linux || darwin) wants a partner carrying the negation:

//go:build !(cgo && (linux || darwin))

Copy the original expression and wrap it in !(...) rather than distributing the negation by hand. A mistake can silently exclude both implementations for some configurations.

Custom tags for optional features

You can also define your own tags and satisfy them with -tags.

//go:build debugtrace

package tracing

import "log"

func trace(msg string) { log.Println("TRACE:", msg) }
//go:build !debugtrace

package tracing

func trace(string) {} // compiled away entirely

Build with go build -tags debugtrace ./.... Without the tag, calls go to the empty implementation, which the compiler will normally inline and eliminate. If the cost matters, confirm that for your program with a benchmark or the generated assembly.

Two conventional tags are worth memorising. //go:build ignore keeps a file out of ordinary builds; it is commonly used for standalone helper programs. Any unsatisfied word would work, but ignore is the convention documented by the go command. And //go:build purego marks a pure-Go alternative to an assembly implementation. It says nothing about cgo or unsafe, so don’t reach for it when you mean the cgo tag.

Constraining by Go version

Since there’s a tag per major release, you can gate a file on language version:

//go:build go1.23

package iterutil

In modules declaring Go 1.21 or later, a release term in the build constraint also sets the language version used to compile that file, at the minimum version the constraint implies. The file above uses Go 1.23 language semantics even when go.mod says go 1.21. This lets one file adopt a new language feature while older toolchains continue to select an alternative file.

Testing across build targets

Test files play by the same rules. The _test suffix comes off before the OS/arch pattern is checked, so store_windows_test.go is a Windows-only test. This is exactly where coverage evaporates without anyone noticing: a platform file with no matching test file has nothing testing it, and go test ./... on your Mac happily reports success for code it never even parsed.

Compile-check the selected non-test files without running them:

for target in linux/amd64 darwin/arm64 windows/amd64; do
  GOOS=${target%/*} GOARCH=${target#*/} CGO_ENABLED=0 go build ./... || exit 1
done

That catches missing symbols, unused imports, and signature drift in files your laptop skips. Test cgo-enabled and cgo-disabled configurations separately on the target platform, because cgo-gated files are a genuinely different build configuration:

CGO_ENABLED=0 GOOS=linux go vet ./...
CGO_ENABLED=1 GOOS=linux go vet ./...

Enabling cgo while cross-compiling also requires a suitable cross C compiler, so a native CI job for each cgo target is usually simpler.

If you want coverage numbers that mean anything, push platform-independent logic into unconstrained files and keep the constrained files thin. Even thin wrappers deserve focused tests when they escape arguments or call operating-system commands. Parsing, validation, and retry logic usually belong in unconstrained files, where every CI runner can execute them.

When a test genuinely has to run per-platform, put the matrix in CI. A GitHub Actions job running go test ./... on ubuntu-latest, macos-latest, and windows-latest gives you real execution. The go build cross-compile loop can then cover additional non-cgo targets, such as freebsd/amd64 or linux/arm.

The old // +build syntax

Go 1.16 and earlier used // +build lines, where space meant OR and comma meant AND. gofmt now adds an equivalent //go:build line when it sees the old form and keeps the two forms in sync. If a codebase still carries both, run go fmt ./.... For a module declaring Go 1.18 or later, go fix ./... removes the obsolete // +build lines.

A checklist for avoiding mystery failures

  • If a package must build outside its explicitly supported targets, give it a fallback file with the negated constraint.
  • Never combine a filename suffix with a contradicting //go:build line.
  • Name fallback files with a suffix that can’t ever become a GOOS (_fallback, _generic, _stub).
  • Check go list -f '{{.IgnoredGoFiles}}' when a symbol is mysteriously undefined.
  • Run go build across your non-cgo target matrix, and test cgo configurations on suitable runners.
  • Put testable logic in unconstrained files so your coverage numbers are real.

If you inherit a repo with platform files scattered around, the first thing to run is that go list -f '{{.IgnoredGoFiles}}' command across every GOOS you claim to support. What comes back is usually a file someone renamed years ago and nobody has compiled since. The full reference lives in the go command documentation, and the parsing rules, including which file types can carry constraints at all, are in go/build.