Build Constraints and Conditional Compilation

A build constraint (historically a “build tag”) is a condition that decides whether a source file is included in a package for a given build. Go has no preprocessor and no #ifdef; conditional compilation is instead expressed at file granularity — a whole file is either compiled or skipped — through two mechanisms: a //go:build comment near the top of the file, and naming conventions in the file name itself (foo_linux.go, bar_amd64_test.go). The constraint comment is “evaluated as an expression containing build tags combined by ||, &&, and ! operators and parentheses,” and the file is compiled only when that expression is true (cmd/go help buildconstraint). Since Go 1.17 the canonical form is //go:build; the older // +build syntax still works but is considered legacy (Go 1.17 release notes).

Mental Model

The right mental model is file selection happens before parsing. When the go command builds a package, it first enumerates the package directory, then for each .go file decides — purely from the file name and the constraint comment — whether that file is part of this build. Only the selected files are handed to the parser. A file that fails its constraint is as if it did not exist: its symbols are absent, its imports are not pulled in, its init functions never run.

This is fundamentally different from the C preprocessor, which operates inside a file at line granularity. Go’s choice — whole-file granularity — is deliberate. It keeps each file independently parseable and gofmt-able, makes the platform-specific surface of a package visible in its file listing, and avoids the combinatorial mess of nested #ifdefs. The cost is that conditional code must be factored into separate files with a shared interface, rather than sprinkled inline.

flowchart TD
  DIR[package directory<br/>net.go net_linux.go net_windows.go] --> LOOP{for each .go file}
  LOOP --> FN[file-name rule:<br/>strip .go and _test,<br/>match *_GOOS *_GOARCH]
  LOOP --> CMT[//go:build comment:<br/>parse boolean expr]
  FN --> AND[implicit AND explicit<br/>constraints combined]
  CMT --> AND
  AND --> EVAL{expression true<br/>for GOOS/GOARCH/tags?}
  EVAL -- yes --> KEEP[file included → parser]
  EVAL -- no --> DROP[file skipped entirely]
  KEEP --> PKG[package compiled]

Diagram: each file in a package directory is independently tested against its file-name-derived constraint AND its //go:build expression; only files whose combined constraint evaluates true for the current GOOS/GOARCH/tag set reach the parser. The insight: selection is per-file and happens before any code is parsed, so a skipped file contributes nothing — not even its imports.

Mechanical Walk-through

Where a constraint may appear

A //go:build constraint “may appear in any kind of source file (not just Go), but they must appear near the top of the file, preceded only by blank lines and other comments. These rules mean that in Go files a build constraint must appear before the package clause” (help buildconstraint). Critically: “To distinguish build constraints from package documentation, a build constraint should be followed by a blank line.” Without the blank line, the tooling may treat the comment as the doc comment for the package clause and miss it as a constraint. “It is an error for a file to have more than one //go:build line.”

The constraint expression

The comment text after //go:build is a boolean expression. The operands are build tags — bare identifiers — and they combine with || (or), && (and), ! (not), and parentheses, “with the same meaning as in Go.” The canonical example: //go:build (linux && 386) || (darwin && !cgo) includes the file when the target is Linux on 386, or Darwin without cgo.

Which tags are satisfied

During a build, the following tags evaluate true (help buildconstraint):

  • The target operating system, “as spelled by runtime.GOOS, set with the GOOS environment variable” — e.g. linux, windows, darwin.
  • The target architecture, “as spelled by runtime.GOARCH, set with the GOARCH environment variable” — e.g. amd64, arm64.
  • Architecture feature tags “in the form GOARCH.feature (for example, amd64.v2)” — set by environment variables like GOAMD64, GOARM, GOARM64, GO386, GOPPC64, and GORISCV64. The complete current list spans 386.387/386.sse2; amd64.v1/amd64.v2/amd64.v3/amd64.v4; arm.5/arm.6/arm.7; arm64.v8.0arm64.v8.9 and arm64.v9.0arm64.v9.5; the mips/mips64 hardfloat/softfloat pairs and their le siblings; ppc64.power8/power9/power10 (and ppc64le); and riscv64.rva20u64/rva22u64/rva23u64 (cmd/go: build constraints). These tags express a microarchitecture floor: building with GOAMD64=v3 satisfies amd64.v1, amd64.v2, and amd64.v3 simultaneously, so //go:build amd64.v3 selects the file when the build is at least v3.
  • unix, “if GOOS is a Unix or Unix-like system” — a convenience tag covering Linux, the BSDs, Darwin, Solaris, AIX and more, so a file need not enumerate them.
  • The compiler, “either gc or gccgo.”
  • cgo, “if the cgo command is supported” — i.e. when CGO_ENABLED=1 and a C toolchain is available.
  • A term for each Go major release through the current version: “go1.1 from Go version 1.1 onward, go1.12 from Go 1.12, and so on. There are no separate build tags for beta or minor releases.” This is what lets a file say //go:build go1.21 to compile only under Go 1.21+.
  • “Any additional tags given by the -tags flag.”

A handful of GOOS values imply others: “Using GOOS=android matches build tags and files as for GOOS=linux in addition to android”; likewise illumos implies solaris, and ios implies darwin.

File-name constraints

Separate from the comment, the file name carries an implicit constraint. “If a file’s name, after stripping the extension and a possible _test suffix, matches any of the following patterns: *_GOOS, *_GOARCH, *_GOOS_GOARCH (example: source_windows_amd64.go) where GOOS and GOARCH represent any known operating system and architecture values respectively, then the file is considered to have an implicit build constraint requiring those terms (in addition to any explicit constraints in the file)” (help buildconstraint). So parser_linux.go compiles only on Linux, atomic_arm64.go only on arm64, mmap_linux_amd64.go only on Linux/amd64 — with no comment at all.

The implicit and explicit constraints are AND-ed: a file named foo_linux.go that also carries //go:build amd64 compiles only on Linux and amd64. The _test suffix interacts with this: parser_windows_test.go is a Windows-only test file — the _test is stripped before the GOOS/GOARCH match, then re-applied as “this is a test file.”

The // +build legacy form and the 1.17 transition

Before Go 1.17 the only comment form was // +build, with idiosyncratic syntax: space meant OR, comma meant AND, a leading ! meant NOT, and it had to be separated from the package clause by a blank line. // +build linux,386 darwin,!cgo meant the same as the modern //go:build (linux && 386) || (darwin && !cgo). The // +build rules were error-prone (space-vs-comma confused everyone) and could not be gofmt-checked for correctness.

Go 1.17 introduced //go:build as a proper boolean expression and made gofmt automatically maintain both forms during the transition: if a file had a // +build line, gofmt would add the equivalent //go:build line, and vice versa, and would flag mismatches (Go 1.17 release notes). The design rationale is in the go:build proposal. New code should use only //go:build; the // +build form is retained for backward compatibility with files that must also compile under pre-1.17 toolchains, but for any module requiring Go 1.17+ it is dead weight and gofmt will not re-add it.

Language-version downgrade

A lesser-known capability noted in the docs: “Build constraints can also be used to downgrade the language version used to compile a file” (help buildconstraint). A //go:build go1.21 line in a module whose go.mod says go 1.23 causes that file to be compiled with Go 1.21 language semantics — relevant for the loop-variable change that shipped in Go 1.22 (Go 1.22 release notes), which is gated on the file’s effective language version. So a single file that wants the pre-1.22 capture behaviour inside an otherwise modern module writes //go:build go1.21 (or any earlier go1.X), and the compiler applies the old semantics to that file only. The reverse — upgrading a single file above the module’s go.mod minimum — is not supported; you cannot use Go 1.22+ syntax in a go 1.20 module by writing //go:build go1.22. The tag is read as “this file requires at least Go 1.22 to select”; selection and language version are coupled but the language version cannot exceed the module floor.

The go/build package perspective

The go/build package exposes a Context struct (GOOS, GOARCH, GOARM, BuildTags, ReleaseTags, CgoEnabled, …) and a MatchFile/ImportDir API that the go command uses internally to enumerate selectable files (go/build). When tools like gopls, godoc, or staticcheck ask “which files would compile right now?”, they construct a Context and call this API rather than re-implementing the rules. The Context.MatchFile function takes a directory and a file name and returns whether that file matches the context’s GOOS/GOARCH/tag set — performing the file-name pattern match, parsing the //go:build comment via go/build/constraint, and AND-ing the two. This is why a custom build tag invented in your own repository “just works” in gopls: it is purely a string the Context.BuildTags slice may or may not contain, and the go/build/constraint evaluator treats unknown tags as false unless explicitly satisfied.

Code Examples

A file-name-constrained pair

// file: clip_darwin.go   — implicit constraint: GOOS == darwin
package clipboard
 
func write(s string) error { return pbcopy(s) }
// file: clip_linux.go    — implicit constraint: GOOS == linux
package clipboard
 
func write(s string) error { return xclip(s) }

Both files declare package clipboard and both define write. There is no conflict because exactly one is selected per build. The package’s public API (clipboard.Write) lives in an unconstrained clip.go that calls the platform write. This is the canonical pattern: a portable file holds the interface, constrained files hold the implementations.

An explicit //go:build comment

//go:build linux && (amd64 || arm64) && !purego
 
package fastpath
 
// SIMD-accelerated implementation, x86-64 and arm64 only,
// disabled when the `purego` build tag is set.

Line 1 is the constraint: included only on Linux, only on amd64 or arm64, and only when the custom tag purego is not passed via -tags. Line 2 is mandatory — the blank line separates the constraint from the package clause so the toolchain does not mistake it for a doc comment. Building with go build -tags purego makes !purego false and excludes this file, letting a fallback file (constrained //go:build purego) take over.

A custom-tag feature flag

//go:build enterprise
 
package licensing
 
func init() { features.Enable("audit-log", "sso") }

Compiled only when invoked as go build -tags enterprise .... The community edition omits the tag, the file is dropped, and the enterprise-only code — including its init and its imports — never enters the binary. This is how a single repository ships two editions without #ifdef. Multiple tags are passed comma-separated: -tags "enterprise,debug".

Programmatic evaluation

The go/build/constraint package parses and evaluates these expressions, which is how gofmt, go vet, and IDEs reason about them:

expr, err := constraint.Parse("//go:build linux && amd64")
ok := expr.Eval(func(tag string) bool {
    return tag == "linux" || tag == "amd64"   // satisfied-tag oracle
})
// ok == true

constraint.Parse accepts either the //go:build or // +build form and returns an expression tree; Eval walks it against a caller-supplied predicate that answers “is this tag satisfied?” (go/build/constraint).

Failure Modes and Common Misunderstandings

Missing blank line after //go:build. The single most common bug. Without the blank line, the comment becomes the package’s doc comment and the constraint is silently ignored — the file is compiled unconditionally. Symptom: a file you expected to be platform-specific compiles everywhere and fails on the wrong OS. go vet flags this (misplaced +build comment / malformed //go:build).

// +build / //go:build mismatch. In a file carrying both forms, if they disagree, the build fails with a gofmt/vet error. After Go 1.17, the safest move is to delete the // +build line entirely unless the file must build under Go 1.16 or earlier.

A constraint that excludes every platform. //go:build linux && windows is never satisfiable (no build is both). The file is simply always skipped — no error — which makes “my code isn’t compiling” mysterious. go list -f '{{.GoFiles}}' shows which files are actually selected for the current settings.

Custom tags are case-sensitive and not validated. -tags Enterprise does not satisfy //go:build enterprise. And a typo’d tag in a constraint (//go:build entrprise) is not an error — it just makes the file unselectable. Nothing warns you.

Constraints do not reach into syntax. You cannot conditionally compile part of a file or a single function. The unit is the file. Code that “almost” works on all platforms but needs one differing line must still be split into per-platform files (often a one-line file per OS).

unix is newer than people expect. The unix build tag was introduced in Go 1.19: “The build constraint unix is now recognized in //go:build lines. The constraint is satisfied if the target operating system, also known as GOOS, is a Unix or Unix-like system. For the 1.19 release it is satisfied if GOOS is one of aix, android, darwin, dragonfly, freebsd, hurd, illumos, ios, linux, netbsd, openbsd, or solaris. In future releases the unix constraint may match additional newly supported operating systems” (Go 1.19 release notes). Files targeting older toolchains must still enumerate linux || darwin || freebsd || ..., and a module whose go.mod declares go 1.18 or earlier cannot use unix even when built with a newer toolchain — the constraint set is gated by the file’s effective language version, not by the compiler in hand.

Release tags do not include minor or pre-release versions. A constraint like //go:build go1.21.5 is not satisfied — only major-release tags (go1.21) exist. There are also no beta or rc tags (cmd/go: build constraints). And the release tag means “Go major release X.Y or later,” so //go:build go1.21 is satisfied under Go 1.22, 1.23, and the current Go 1.26; it is not an “exactly 1.21” constraint.

Comma is OR in // +build, AND in //go:build. When porting a legacy // +build a,b c (which meant “(a AND b) OR c”) to //go:build, the natural-looking translation //go:build a, b, c is a syntax error — //go:build does not use comma at all. The correct rewrite is //go:build (a && b) || c. gofmt does this translation automatically, so the safe procedure is gofmt -w . before deleting any // +build lines.

Alternatives and When to Choose Them

When conditional behavior is needed, the options in rough order of preference are: (1) file-name constraints — zero ceremony, ideal for clean per-OS/per-arch splits; (2) //go:build comments — when the condition is a boolean of several tags or involves a custom feature flag; (3) runtime branching on runtime.GOOS/runtime.GOARCH — when the platforms genuinely share most code and only a small value differs (but note this compiles all branches into the binary); (4) //go:build ignore — a conventional tag, satisfied by no normal build, used to mark standalone helper files (a main.go run via go run for code generation) that should not be part of the package.

Constraints are not a substitute for runtime feature detection. CPU-feature dispatch (does this chip have AVX-512?) is a runtime decision via internal/cpu, not a build constraint — GOAMD64=v3 sets a floor, not a guarantee of any specific instruction. See Cross Compilation in Go for the GOAMD64/GOARM microarchitecture levels.

Production Notes

The Go standard library is the largest real-world user of build constraints: runtime, syscall, os, and net are pervasively split into _linux.go, _windows.go, _darwin.go files plus shared portable files. Reading src/runtime/ is an education in the file-name convention.

A practical CI lesson: a constraint bug — a file silently excluded on a platform you do not test — is invisible until that platform breaks. Teams shipping cross-platform binaries run go build (or at least go vet) for every supported GOOS/GOARCH in CI precisely so a misplaced or unsatisfiable constraint surfaces immediately rather than in a user’s bug report. go vet specifically checks //go:build lines for malformed syntax and for // +build mismatches.

The purego tag has become a de-facto community convention: many performance libraries ship hand-written assembly guarded so that go build -tags purego falls back to pure-Go implementations — essential for platforms the assembly does not cover and for tools (like some WebAssembly targets) that cannot consume the assembly. Libraries such as golang.org/x/sys, golang.org/x/crypto, lukechampine.com/blake3, and github.com/cespare/xxhash all follow this convention; reading their source is a tour through real-world //go:build patterns combining amd64.v3, arm64, and !purego to select among assembly tiers.

A second pattern worth knowing is build-tag stratification for microarchitecture dispatch. Inside a package that ships AVX-512 routines, the file layout commonly looks like:

hash.go               // pure-Go fallback, unconstrained
hash_amd64.go         // amd64-only glue, dispatches to v1/v2/v3 routines
hash_amd64_v3.s       //go:build amd64 && amd64.v3   — AVX2-using assembly
hash_amd64_v3_test.go //go:build amd64 && amd64.v3
hash_purego.go        //go:build purego               — disables assembly

The combined effect: a build with GOAMD64=v3 selects the v3 assembly file and the regular amd64 glue; a build with GOAMD64=v1 (the default) selects only the glue, which then dispatches at runtime via internal/cpu for finer-grained feature detection. purego forces the unconstrained hash.go to be the only implementation. Each layer is a separate file; there is no #ifdef, no #if defined(__AVX2__). This is verbose compared to a C codebase but stays parseable, formattable, and inspectable file-by-file.

A third lesson: build constraints interact with Go Modules vendoring in a subtle way. go mod vendor copies all files of a module’s selected packages — even those that fail the current build’s constraints — into vendor/, so vendored trees include files for every OS/arch the module supports. This is intentional: vendoring is meant to enable cross-compilation from the vendored tree without the original modules. The cost is vendor directory bloat; the benefit is that GOOS=windows go build against vendor/ works even if the vendoring host was Linux.

See Also