Go build tags: pros, cons and all the useful things you can do with them
Go build tags have been around for a very long time. A lot of us, who’ve dipped their toes into big go projects may have encountered them, especially the built-in ones. In this article I am not going to try and surprise you with some unexpected features of build tags system, but rather will try to summarize all the existing information in the net about them, provide some examples from real codebases and personal advice on build tags usage.
What are the build tags?
Build constraints, as they are called in the official documentation, is a system designed to tell the compiler (the program that converts your source files into an executable) which files to include in or exclude from the resulting binary.
Build constraints are able to affect only the files that are processed by the compiler directly: .go files for golang source code, .s for golang assembler, and there is also a way to constraint .c and .h files used for inter operation with C (remember, kids: CGO is not Go), but about that a bit later.
To clarify some misconceptions: go run, go install and go test do also build an executable, just like go build does, though this executable is then immediately deleted (or moved in the case of go install) so that may slip your attention. Thus, build tags also work with those commands.
Moreover, go vet also supports tags.
Ways to mark file with a build constraint
The first one is to include the constraint in the filename in several formats like:
*_$GOOS
*_$GOARCH
*_$GOOS_$GOARCH
For example: print_ios_darwin_test.go is a test file ran only when target is IOS.
But this has a major downside: it only works with built-in tags for
GOOS — the target operating system for compilation and
GOARCH — the target processor architecture
For all possible values of those see go source code or official documentation.
Therefore, there is another way: special kind of a magic comment, that is placed right at the start of the file. Only blank lines or other line comments may be placed above it (even the package declaration is situated below the go:build directive).
This magic comment in the newer versions of Go is written as follows:
- You start with a comment that begins like:
//go:build
2. And then add the constraint expression after that like so:
//go:build integration && db_tests
Constraint expressions consist of several tags, that are joined together using boolean operators of and “&&”, or “||” and negation “!”, and parentheses to ensure the order of expression evaluation.
Tag names may contain unicode letters (according to unicode.IsLetter), unicode digits (according to unicode.IsDigits) and “_” and “.” characters. [source]
Thus, even this nonsense may be considered a valid tag expression:
//go:build (0_ετικέτα.𝟨AΔ && integration) || !pg.db
I’ve mentioned before, that this is the way that magic comment is written in newer version of go, in legacy code bases you may notice build tags written like this:
// +build smthsmth
I won’t go into much details about how to write those expressions, but several points about that worth to mention:
- Empty value, “!”, invalid values and values with double negation like “!!tag” mean that the file will never be included in a build
- Newer go fmt will automatically add a build tag in a newer format for files with the directive in a deprecated form
- Commas mean AND, spaces mean OR, exclamation mark is a negation
// +build tag1,tag2 !tag3
//go:build tag1 && tag2 || !tag3
Built-in tags
Whenever a build process is being run several build constraints are satisfied by default:
- value of the GOOS variable — the target operating system
- value of the GOARCH variable — the target architecture
- “unix” if GOOS is a Unix or Unix-like system
- “gc” if a classical go compiler is being used
- “gccgo” and “cgo” if interoperability between C and Go code has been introduced by using CGO
- tag similar in name to the major go release that is used for compilation like: “go1.11”, “go1.20”
CGO
When dealing with CGO, we are presented with a special pseudo-directive for C source files: #cgo, that is used to tweak the C, C++ or Fortran compiler behavior by providing special flags. I won’t go into much details about, how those flags work, but only provide the example related to this topic of build constraints. Any intrigued user may want to check out golang’s official documentation on this.
#cgo CFLAGS: -DPNG_DEBUG=1
#cgo amd64 386 CFLAGS: -DX86=1
#cgo LDFLAGS: -lpng
#include <png.h>
-DPNG_DEBUG=1 (for C compiler) and -lpng (for the linker) will be used for any platform, but when targeting AMD’s or Intel’s x64 processors -DX86=1 will also join the C compiler flags gang.
What can you do with build constraints?
Split platform-specific code
Well, to begin with, Golang already benefits from the build tags system much. Go’s compiler is self-compiling, which means it’s written completely in Go (and some Go specific assembly).
It’s obvious, that it’s not always that you can write the same low-level code for multiple vastly different platforms like smartphones (yeah, Golang can run on your phone) and desktop devices. Thus, there is a need to implement similar features like access to OS components, some low-level super-optimized operations differently for different OSs and architectures. That is precisely what built-in GOOS and GOARCH based build constraints solve!
Right now you can just skim through some golang source code (dir_windows.go and dir_unix.go) and find “readdir” method reimplemented several times in different files, which would be illegal in normal circumstances, but not since the filenames are in a special format that makes them built only under special target OS and ARCH as we have figured out before.
Nothing stops you from doing that, though often it’s not the best practice, since in the majority of cases Golang’s standard library handles cross-platform applications really well, but if you’re writing some low-level code there is no way you can ignore GOOS and GOARCH based build tags.
Make some features toggleable
Sometimes you want to distribute or deploy your go program with a different set of features. It may be so that you have a community version of your app and the enterprise one, source code for which is a fork of open-source community version, with paid features locked under a build tag. Otherwise you may want to strip the resulting executable from everything that is not going to be necessary in the current application of the program. Like Docker allows, when building your own engine from scratch, to disable specific filesystems, if you don’t need them.
To do that there are multiple ways, I will provide the simplest example of, if I may call it so, a feature registry.
// main.go
package main
// imports are ommitted, as well as any kind of real logic behind this file
var features = []Feature
func main() {
app := NewApp()
for _, f := range features {
app.Register(f)
}
app.Run()
}
// ---
//go:build auth
// auth.go
package main
func init() {
features = append(features, NewAuthFeature())
}
Therefore, when compiling with go build -tags auth we will include the AuthFeature in our application, otherwise there will be none of that!
Make some configurable trade-offs
Part of what makes software engineering so complicated is making decisions, especially ones, where the right choice is not so obvious.
When we’re dealing with software, that is going to be deployed by our users, we inevitably have to leave that choice to them. Because the trade-offs that we’re ready to deal with aren’t likely to match with the ones, that our customers are ready to accept.
Classic example of that is trading all the good of not using CGO for some performance boost like in the case of Pocketbase’s choice of SQLite driver: with cgo and without.
Hide some files from go tools / Track dev dependencies
Sometimes you’d like to have a go source code, that is never going to be used in neither tests, nor final build of the application you’re working on. Quite often I see this used to track development dependencies like Prometheus does. If you know any other example of that, I would love to hear about that in the comments!
Isolate some tests
There are many kinds of tests. Some are long and expensive and require specific environment, but allow you to completely verify some complicated scenarios. Some are real fast and useful to catch silly mistakes you may have made along the way of code modifications. You wouldn’t want to to run the heavy tests all the time, having to provide necessary environment. This is where build constraints shine!
//go:build db_test
// db_test.go
package main_test
// takes minutes to complete
func TestDbConnection(t *testing.T) {
createDB()
insertData()
assertDBIsUpToDate()
}
// ---
// unit_test.go
package main_test
// takes ms to complete
func TestBusinessLogic(t *testing.T) {
assertBusinessLogicIsFine()
}
I quite often see this pattern misused, when the build tag is being used only to separate long tests from the short ones. For this application, I would advise to use testing.Short() method, it’s literally designed for this sole purpose. Though, sometimes there are different kinds of “long and heavy” tests, in this case build tags are your friends once again like in Watermill by ThreeDotsLabs.
Compile-time configuration
I haven’t ever seen build tags being used to configure the behavior of the application during compilation. That may be for a good reason, if so, I would love to hear your opinion in the comments.
Despite that after using DWM for some time I do find some crucial benefits in tuning something, when compiling the software, like in this example:
//go:build log.info || log.warn
// warn_enabled.go
package log
func Warn(message string) {
logger.Log(message)
}
//go:build !log.info && !log.warn
// warn_disabled.go
package log
func Warn(message string) {}
In this code log.Warn will only do anything, when the application is compiled with “log.info” or “log.warn” tags, and otherwise in the places, where the logger would have been called - there won’t even be a function call, which means it may be a good decision for a very performance critical, but still desirably observable piece of code.
Pitfalls of build tags
After talking so much about how good and useful build tags may be, let’s for a minute focus on some downsides to incorporating them to your codebase.
One of the obvious ones is that build constraints obscure and bring some magic to the process of figuring out, what exactly is going to be in a certain build of your application. It’s crucial not to overuse them and to isolate as much code from those compile-time conditions as possible to ensure the maintainability of your codebase.
Moreover, it’s very easy to fall into a combinatorics game of trying to test, whether all possible combinations of build tags result in a valid executable program.
Finally, build constraints are not really IDE-friendly. Most of popular IDEs like Goland and VSCode by default assume that no build tags are provided (which, honestly, is a good default behavior to assume). It makes it quite painful to write code, that is locked behind a build tag. To help it, there are usually settings to change the assumed set of default active build constraints.
In VSCode that is:
or “go.buildTags” in JSON
For JetBrains IDEs the solution is thoroughly described in the official documentation.
The end
In conclusion, build constraints are cool and useful, but there are some pitfalls, that are actually quite easy to fix, nevertheless don’t overuse them, but be open to introduce some quality of life improvement to your development process and user experience with this amazing tool!