btema.net

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.

#Golang #Prometheus