33.3 Custom Build Tags for Feature Flags
Right, so you want to control your application’s behavior at compile time, not runtime. You’re tired of managing a rats’ nest of environment variables and config files for features that should be baked in or left out entirely. Welcome to the big leagues. This is where we stop asking “is this feature enabled?” and start telling the compiler, “this feature is the binary.”
We’re talking about feature flags, but the kind you can’t change without a recompile. The kind that strips entire chunks of code out, leaving no trace. It’s incredibly powerful for creating lean, purpose-built binaries, and it’s done with one of Go’s simplest yet most misunderstood features: build tags.
How Build Tags Actually Work (It’s Not Magic)
Forget what you think you know for a second. A build tag isn’t some special directive inside a function. It’s a comment at the very top of a .go file that says, “Hey, go build, only include this file if these conditions are met.” The compiler parses these comments before it even thinks about the package declaration.
The syntax is dead simple. You can have a single tag:
//go:build enterprise
// +build enterprise
package main
var SuperSecretEnterpriseFeature = true
Or combine them with Boolean logic. Want a file only for Linux AND the enterprise tag? No problem.
//go:build linux && enterprise
// +build linux,enterprise
package main
import "fmt"
func init() {
fmt.Println("You are running the super-secret Linux enterprise feature.")
}
Notice the two lines? //go:build is the new, officially recommended syntax (as of Go 1.17) that uses proper Boolean grammar. // +build is the older, slightly clunkier form. You should use the new one, but for now, it’s good practice to include both for backward compatibility with older Go tools. The logic is the same: && becomes a comma, || becomes a space, and ! means exclude.
The Anatomy of a go build Command
You don’t just “set” a build tag like an environment variable. You invoke the compiler with a specific set of constraints. This is the most common pitfall: thinking tags are part of your program’s environment. They’re not. They’re part of the compiler’s.
To build the enterprise version of our app, you’d run:
go build -tags="enterprise" -o myapp_enterprise .
To build a version that includes a feature for both Linux and the dev tag, you’d use:
GOOS=linux go build -tags="dev" -o myapp_linux_dev .
The compiler gathers all the .go files in the package, looks at their tags, and only includes those that match the requested combination of GOOS, GOARCH, and your custom -tags. Files with no tags at all? They’re always included. They’re the common core.
The Inverse: Ignoring Files with ignore
Sometimes it’s clearer to define what you don’t want. This is where the ignore tag comes in, and it’s a lifesaver for debugging or excluding problematic code.
//go:build ignore
// +build ignore
package main
// This isn't a real program; it's a scratchpad for testing the data processor.
// By tagging it 'ignore', 'go build' will skip it, but I can still run it with 'go run scratchpad.go'
func main() {
// ... messy experimental code ...
}
A file tagged with ignore is excluded from the normal build process, but you can still run it directly. It’s like putting a “not for production” sign on a file.
Best Practices and How to Shoot Yourself in the Foot
Naming is Everything: Your tags should be clear and unambiguous.
//go:build prodis terrifyingly vague.//go:build feature_metrics_datadogis explicit. Prefer the latter. Also, tags are case-sensitive.enterpriseis not the same asEnterprise.The Default Build Must Work: This is non-negotiable. Running a plain
go buildwith no tags must result in a functioning, standard binary. Your tagged files should add features, not contain the core logic required for the program to start. If your program fails without a specific tag, your design is broken.Beware of Tag Darwinism: The combinatorial explosion of tags is a real problem. If you have
feature_a,feature_b, andfeature_c, you now have 8 possible binary combinations to test. It’s a maintenance nightmare. Use tags for large, architectural variations (likeenterprisevs.oss), not for every tiny toggle. For fine-grained control, use runtime flags.Testing with Tags: Your tagged code needs tested, right? So you must use tags when running tests too.
go test -tags="enterprise" ./... # test everything with enterprise features go test ./... # test only the core, tagless functionalityForgetting to do this is how untested, tag-gated code sneaks into your codebase and breaks everything the first time someone actually uses the tag.
Build tags are a scalpel, not a sledgehammer. They’re for making fundamental, compile-time decisions that shape the very architecture of your binary. Use them wisely, and you’ll create binaries that are streamlined, secure, and built for one exact purpose.