Some code only makes sense on certain operating systems, certain CPU architectures, or under certain feature flags. Build tags (officially called build constraints) let you tell the Go toolchain "compile this file only when these conditions hold". They're how Go ships a single source tree that targets Linux and Windows, with and without cgo, with and without paid features, all from the same go build command.
Consider a checkout service that wants to read a hardware-backed secure enclave on macOS but fall back to a software key store everywhere else. The macOS code calls into a Darwin-only syscall that won't even compile on Linux. You can't put both implementations in one file. You can't gate them with if runtime.GOOS == "darwin" either, because the compiler still has to parse the Darwin code on Linux, and the syscall package it imports doesn't exist on Linux.
The answer is to give the compiler a hint at the file level: "skip this file unless you're targeting macOS". That hint is a build tag.
Both files declare package keystore and both export Load. The toolchain picks exactly one when it builds the binary, based on which GOOS you're targeting. The caller writes keystore.Load("payment") without knowing or caring which file holds the real implementation.
That's the core idea. Build tags are compile-time switches that include or exclude entire files. Everything else in this chapter is mechanics.
// +build Syntax vs the New //go:buildGo 1.17 introduced a cleaner syntax for build tags. The old form looked like a magic comment:
The new form looks like a directive:
Both still work. gofmt from Go 1.17+ automatically adds the //go:build line when it sees the old // +build line, so most codebases now have both lines side by side. New code should use //go:build only. The toolchain treats them as authoritative when both appear and warns if they disagree.
The new syntax is better for two reasons. First, it uses real boolean operators (&&, ||, !, parentheses) instead of the old comma-and-space convention. Second, it shares a parser with the rest of the Go toolchain, so error messages point at the right column when you typo something.
The old form above means "(linux OR darwin) AND (NOT cgo)". The new form spells it out. If you're maintaining a codebase that targets Go 1.16 or older, keep both lines. Otherwise, drop the // +build line and use only //go:build.
Build tags only work when placed correctly. The rules are strict.
The //go:build line must appear at the very top of the file, before the package declaration. A blank line must follow it. Doc comments and copyright headers go above the build tag, not between the build tag and the package line.
If you forget the blank line after the build tag, the toolchain treats //go:build as a regular comment attached to the package, and the constraint is ignored. The file then compiles on every platform, which is usually the exact opposite of what you wanted. There's no warning. Your CI runs green on Linux and breaks on macOS three weeks later when someone runs the same test.
A safe checklist for placement:
//go:build directive.package declaration.Get the blank line wrong and you've effectively deleted the constraint.
A missing blank line after //go:build disables the constraint. go vet catches it (misplaced +build comment) in Go 1.17+, so add go vet./... to CI if you're not already running it.
The expression after //go:build is a boolean built from tag names, the operators &&, ||, !, and parentheses. Tag names are identifiers: letters, digits, and underscores, starting with a letter.
Some common shapes:
A tag evaluates to true if it's currently "active". A tag is active when:
GOOS (linux, darwin, windows, freebsd, etc.).GOARCH (amd64, arm64, 386, wasm, etc.).cgo, gc, gccgo, unix).go1.21 is active under Go 1.21 and every later release).-tags, like go build -tags=integration.Everything else is false. Misspelled tag names don't error out; they just evaluate to false. That's another silent common pitfall.
go vet doesn't catch this either, because the toolchain has no idea what tags your code defines. You'll only notice when a function disappears from your binary and go build complains about an undefined symbol somewhere else.
Build tags aren't the only way to gate a file. Go also recognizes specific filename patterns and applies implicit constraints based on them. This is how the standard library handles most platform-specific code without writing a single //go:build line.
The patterns:
| Filename ends with | Implicit constraint |
|---|---|
_linux.go | //go:build linux |
_darwin.go | //go:build darwin |
_windows.go | //go:build windows |
_amd64.go | //go:build amd64 |
_arm64.go | //go:build arm64 |
_linux_amd64.go | //go:build linux && amd64 |
_darwin_arm64.go | //go:build darwin && arm64 |
_test.go | Test-only (handled by go test, not go build) |
The toolchain recognizes the GOOS and GOARCH names listed by go tool dist list. Use these suffixes when the file is entirely OS-specific or arch-specific. Use a //go:build line when the constraint is more complex than "this one OS plus this one architecture".
The _darwin.go suffix is enough for the macOS file. The fallback file needs an explicit //go:build !darwin because there's no filename pattern for "everything except macOS".
A subtlety: filename suffixes and //go:build directives compose with AND, not OR. A file named cache_linux.go with //go:build amd64 at the top compiles only on Linux AND amd64. You can't widen the constraint by using a suffix; you can only narrow it further.
The toolchain sets a handful of tags automatically. These are the ones that come up in real code:
| Tag | Set when |
|---|---|
linux, darwin, windows, freebsd, openbsd, netbsd | Current GOOS matches |
amd64, arm64, 386, arm, wasm, riscv64 | Current GOARCH matches |
unix | Any Unix-like OS (added in Go 1.19) |
cgo | cgo is enabled for this build |
gc | The standard gc compiler (almost always true) |
gccgo | The gccgo compiler (rarely true) |
race | Building with -race |
go1.18, go1.19,..., go1.23 | Current Go version is at least this version |
The Go version tags are how the standard library guards against using new APIs in older releases. A file with //go:build go1.23 compiles only under Go 1.23 or later, which is useful when you want to provide a fast path on newer toolchains while keeping an older fallback.
This pattern keeps a library compatible with multiple Go versions. The newer file activates on Go 1.23+, the older file activates on every earlier release.
The unix tag is convenient when you'd otherwise write linux || darwin || freebsd || openbsd || netbsd. Just say //go:build unix and the toolchain expands it correctly. Note that unix is active on macOS too, since Darwin counts as Unix-like.
The interesting use of build tags is custom ones. You invent a tag name, gate a file with it, and pass -tags=name to go build or go test to flip it on. There's no central registry; tag names are just strings you pick.
A common case is an "integration" tag that gates expensive tests:
Without -tags, the file is invisible. go test doesn't compile it, doesn't run it, doesn't even know it exists. Running the integration suite is then explicit:
You can pass multiple tags by separating with commas (no spaces):
Each comma-separated value becomes an active tag for the build. The order doesn't matter.
Tags only gate inclusion, not exclusion. There's no -tags=-foo to turn a tag off. If you want "include unless this tag is set", the file needs //go:build !foo and you set the tag to disable it. That inversion is a common pitfall the first time.
One of the most common build-tag patterns in real Go code is the "default plus override" structure. You have a default implementation that works almost everywhere, and a handful of files that override it on specific platforms.
The three implementation files each define the same unexported function (say, readKey), and the public API in keystore.go just calls it. The toolchain picks exactly one of the three based on the target platform.
Every file declares readKey with the same signature. Exactly one file is active at compile time, so there's no duplicate-definition error. The public keystore.Load is identical across platforms, which keeps callers blissfully ignorant of the OS plumbing underneath.
This is how net, os, and syscall in the standard library work. Look at the net package source and there are dozens of files like fd_linux.go, fd_darwin.go, and fd_windows.go, each implementing the same internal functions in OS-specific ways.
When go build processes a directory, it walks every .go file and decides whether to include it. The logic is:
The suffix check runs first because it's the cheapest filter. If a file is named foo_windows.go and you're building for Linux, the toolchain skips it without parsing anything. Only when the suffix passes does the parser open the file to look for a //go:build line. And only when both checks pass does the file actually get compiled.
That ordering is why mismatched suffixes-plus-directives can produce a file that's never built. service_linux.go with //go:build darwin at the top will never compile anywhere: the suffix demands Linux, the directive demands Darwin, and you can't be both at once. Run go list -f '{{.GoFiles}}'./... on each target platform to see exactly which files the toolchain is including.
Build tags show up most often in five places.
Platform-specific syscalls. The syscall and os packages in the standard library are entirely built on platform tags. Your own code does the same when you call into a Windows API, a macOS framework, or a Linux-only ioctl. Each platform gets its own file; the public API stays uniform.
Optional features. A monitoring library might support Prometheus, StatsD, and OpenTelemetry exporters. Linking all three into every binary bloats it. With build tags, each exporter lives in a file gated by its own tag, and you build only the ones you need:
Test-only code. Fakes, fixtures, and mocks belong in _test.go files so they don't ship to production. But sometimes you want a helper that's available across multiple test packages. A testhelpers/ package with //go:build testhelpers files is one way to share without leaking into production builds.
Integration tests. Slow tests that hit real services, real databases, or real networks live behind //go:build integration. go test./... stays fast for local iteration; CI runs go test -tags=integration./... on a schedule.
Paid-feature gating. Open-core products often use an enterprise tag that switches in licensed code paths at compile time. The community binary doesn't include the enterprise files at all, which keeps the licensed code physically separate from the open-source distribution.
-tags=integration Test WorkflowThe integration-test pattern is worth a closer look because it shows up in almost every production Go codebase.
Consider a payment package that charges credit cards. Unit tests use a fake gateway; integration tests hit the real (sandbox) Stripe API.
The local feedback loop stays quick:
CI runs the wider sweep:
Three useful patterns layer on top of this:
//go:build integration with t.Skip for tests that need both the tag and a non-empty environment variable. The tag gates compilation; the env check gates execution when the tag is set but credentials aren't.integration for tests that need infrastructure, e2e for tests that drive a full deployment, slow for tests that take more than a few seconds.go build -tags=integration./... in CI even if you don't run the tests. That catches compilation errors in integration files before someone tries to run them.go test caches results by package, but the cache key includes the active tags. Switching between go test./... and go test -tags=integration./... rebuilds both test binaries. On a large module that's a real cost during local iteration. Keep tagged tests in their own subpackages when possible to avoid rebuilding the whole world.
A common shape in commercial Go projects is an open-core binary with paid features bolted on at build time. The free build doesn't even contain the paid code; it lives in files gated by an enterprise tag.
Consider an online shop where the free tier supports a single product catalog, and the enterprise tier adds multi-region replication, SSO, and an audit log. The whole thing builds from one repo:
The stub file makes the public API of the audit package work in both builds:
Callers across the codebase write audit.Log("checkout.completed", details) unconditionally. In the free build, that's a no-op the inliner deletes entirely. In the enterprise build, it writes to the audit sink.
The same source tree produces two binaries. The free binary doesn't link any of the enterprise files. That's a security feature as well as a packaging feature: if a customer reverse-engineers the free binary, the enterprise code isn't there to be reverse-engineered.
There are subtler trade-offs to be aware of. The stub and the enterprise implementation must keep the same function signatures, or the build breaks in one configuration. Tests for the audit package need to run twice, once with the tag and once without, to cover both paths. Some teams add a CI job that runs go build -tags=enterprise./... plus go vet -tags=enterprise./... to keep both worlds honest.
Maintaining parallel implementations behind a tag doubles the test surface for any function gated by the tag. Run both configurations in CI. A green free build doesn't prove the enterprise build still compiles.
When build constraints get complex, it helps to ask the toolchain directly what files it picked. Two commands cover most diagnostics:
The output is the actual list of files Go compiles for the given target. Switch GOOS or pass -tags=... and rerun to see the file set change.
For deeper visibility, go build -x (verbose) prints every command the toolchain runs, including which files get passed to the compiler. It's noisy, but it leaves no doubt about what's happening.
When a build constraint excludes a file, the symptom is usually a compilation error elsewhere: "undefined: somefunc" or "missing function body". That's the toolchain telling you the file it expected to find the symbol in was excluded. Run go list -f '{{.GoFiles}}' with the same target as the failing build to confirm.
A short tour of the mistakes that come up most often:
Missing blank line after the directive. The file then compiles on every platform, ignoring the constraint. Always add the blank line and let gofmt enforce it.
Old-syntax comma where a boolean operator belongs. //go:build linux,amd64 doesn't mean "linux AND amd64" in the new syntax. It's a syntax error and the toolchain refuses to compile. Use linux && amd64.
Typo in a tag name. //go:build linxu compiles to "always false" because linxu is just an undefined tag. The file is never built. go vet doesn't flag this. A linter like staticcheck (with its SA4023 analyzer) sometimes catches dead files; otherwise it's a code-review-only check.
Conflicting suffix and directive. service_linux.go with //go:build darwin is a contradiction. Neither suffix nor directive lets the file compile anywhere. Pick one or make them consistent.
Forgetting `!` on the fallback file. A "default" implementation needs //go:build !specific_tag to be active when the specific tag is off. People sometimes write the specific-platform tag on the wrong file, which makes both files compile on the specific platform and triggers a duplicate-definition error.
Putting `//go:build` after `package`. The directive must appear before package. After the package line, it's just a comment. The toolchain doesn't warn.
10 quizzes