Two Elegant Use Cases for Go Build Tags
I won’t explain what Go build tags are, or conditional compilation in general. There are already many good resources that cover those topics well.
Instead, I want to highlight two use cases that I’ve rarely seen discussed explicitly. In both cases, build tags proved extremely handy, and the resulting solutions turned out quite elegant, at least to my taste.
Providing a Unified Interface for Toolchain Experiments
I’ve been interested in the synctest package since its
addition behind GOEXPERIMENT=synctest in Go 1.24. When it went GA in Go 1.25, I wanted to try it
on some flaky tests in the Prometheus codebase. These
tests involve concurrent code that deals with time. I won’t dive into the details here; this
blog post is a great starting point for understanding when and
how synctest can help.
Prometheus must support the two latest Go versions (which, at the time of writing, are Go 1.24 and
Go 1.25). I didn’t want to wait six months until Go 1.26 is released and GOEXPERIMENT is no
longer a concern in Go 1.24 environments.
A minor annoyance is that the synctest API changed between versions. In Go 1.24:
func Run(f func())
func Wait()
In Go 1.25:
func Test(t *testing.T, f func(*testing.T))
func Wait()
So I needed to provide a unified interface for Go 1.24+, ideally exposing the two functions with
their newer signatures, while supporting Go 1.24 environments both with and without
GOEXPERIMENT=synctest set.
Here’s how to achieve this using build tags. The interface is defined under testutil/synctest:
testutil/synctest/synctest.go contains the definition for Go 1.25+, a straightforward
wrapper:
//go:build go1.25
package synctest
import (
"testing"
"testing/synctest"
)
func Test(t *testing.T, f func(t *testing.T)) {
synctest.Test(t, f)
}
func Wait() {
synctest.Wait()
}
testutil/synctest/enabled.go covers Go 1.24 environments where GOEXPERIMENT=synctest is
set. The wrapper here adapts the old API to the new interface:
//go:build goexperiment.synctest && !go1.25
package synctest
import (
"testing"
"testing/synctest"
)
func Test(t *testing.T, f func(t *testing.T)) {
synctest.Run(func() {
f(t)
})
}
func Wait() {
synctest.Wait()
}
I could have added && go1.24 to be more precise, but go.mod already prevents using Go < 1.24.
testutil/synctest/disabled.go handles Go 1.24 environments where the experiment isn’t
enabled. After all, there’s no way to force GOEXPERIMENT=synctest in every command a Prometheus
developer might run locally. For these environments, the test is simply skipped:
//go:build !goexperiment.synctest && !go1.25
package synctest
import (
"testing"
)
func Test(t *testing.T, f func(t *testing.T)) {
t.Skip("goexperiment.synctest is not enabled")
}
func Wait() {
// Not meant to be called outside of Test().
panic("goexperiment.synctest is not enabled")
}
The new synctest package can then be used like this:
import (
// ...
"testutil/synctest"
// ...
)
func TestShutdown(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
// ...
synctest.Wait()
// ...
})
}
Of course, one should ensure all CI steps (linting, testing, building, etc.) run across all three environments.
This approach can be applied to other experiments that recently went GA, allowing early adoption while still supporting older Go versions.
For more details, check out the Prometheus PR
where these changes were made. Using synctest on one of the flaky tests helped stabilize it as
expected and, more importantly, helped identify a goroutine leak that was fixed as part of that PR.
More tests are planned to migrate to synctest. Thanks to the Go team for this excellent addition!
Using Build Tags to Force Code Paths in Tests
This use case also involves experiments, but from Prometheus’s perspective: what Prometheus calls feature flags.
While adding support for
direct I/O in Prometheus
(which you can try by running Prometheus with --enable-feature=use-uncached-io on recent
versions), I ended up building a directIOWriter. It’s similar to bufio.Writer but knows how to
handle direct I/O constraints. Check out the related
PR for more details. I think it deserves its
own post someday.
Since this experimental feature is only available on Linux for now, I ended up with these build-tagged files:
fileutil/direct_io_unsupported.go for non-Linux environments, where the directIOWriter
constructor returns an error:
//go:build !linux
package fileutil
import (
"bufio"
"os"
)
func NewBufioWriterWithSize(f *os.File, size int) (BufWriter, error) {
return &writer{bufio.NewWriterSize(f, size)}, nil
}
func NewDirectIOWriterWithSize(_ *os.File, _ int) (BufWriter, error) {
return nil, errDirectIOUnsupported
}
fileutil/direct_io_linux.go for Linux environments, where users can choose between the two
writers:
//go:build linux && !forcedirectio
package fileutil
import (
"bufio"
"os"
)
func NewBufioWriterWithSize(f *os.File, size int) (BufWriter, error) {
return &writer{bufio.NewWriterSize(f, size)}, nil
}
func NewDirectIOWriterWithSize(f *os.File, size int) (BufWriter, error) {
return newDirectIOWriter(f, size)
}
The appropriate constructor is called based on whether --enable-feature=use-uncached-io was set:
// ...
if w.useUncachedIO {
wbuf, err = fileutil.NewDirectIOWriterWithSize(f, size)
} else {
wbuf, err = fileutil.NewBufioWriterWithSize(f, size)
}
// ...
But wait, did you notice the forcedirectio build tag in fileutil/direct_io_linux.go? What’s
that about?
There’s actually a third file, fileutil/direct_io_force.go:
//go:build linux && forcedirectio
package fileutil
import "os"
func NewBufioWriterWithSize(f *os.File, size int) (BufWriter, error) {
return NewDirectIOWriterWithSize(f, size)
}
func NewDirectIOWriterWithSize(f *os.File, size int) (BufWriter, error) {
return newDirectIOWriter(f, size)
}
The forcedirectio build tag, as the name suggests, forces the use of directIOWriter regardless
of whether --enable-feature=use-uncached-io is set.
This tag is primarily used for testing. The goal is for all tests that exercise writing to also run
using the new directIOWriter. Modifying every existing test to exercise both writers, and
ensuring all future tests do the same, would be tedious and error-prone. Instead, it’s enough to
add an extra CI step:
go test --tags=forcedirectio ./tsdb/
And that’s it! All existing tests that exercise NewBufioWriterWithSize will now exercise
NewDirectIOWriterWithSize instead, without any modifications.
Build tags can be very useful for tasks beyond simple platform-specific compilation. However, like anything else, they should be used in moderation.