Modern Go idioms

Go is known for its backward compatibility, simplicity, and six-month release cycle. But that can sometimes lead to code that works but isn’t as readable and modern as it could be. This post is a living document where I note modern Go idioms I’ve applied in production codebases to improve clarity and maintainability.

Use GOOS and GOARCH in file names for build constraints

When targeting specific operating systems or architectures, Go lets us name files like foo_linux.go or foo_windows_amd64.go. This naming convention acts as an implicit build constraint, meaning we don’t need to add //go:build linux or //go:build windows && amd64 manually.

A Go source file is considered to have an implicit build constraint if its name (after stripping .go and an optional _test suffix) matches any of these patterns: *_GOOS, *_GOARCH, and *_GOOS_GOARCH (e.g., source_windows_amd64.go). This idiom is widely used in the Go standard library, e.g., sys_linux.go. Learn more about build constraints.

Use the new build constraint syntax: go:build

Go 1.17 introduced the //go:build syntax and prefers it over // +build. In old code, you might see a mix of both styles, which can confuse readers. To standardize your codebase, run:

1go fix -fix=buildtag ./...

This converts +build to go:build. Learn more about Go fix

Use "go test -args" to pass arguments to tests

We don't call flag.Parse() in Go test files. Instead, go test handles flag parsing for us. If a flag isn't recognized as a testing flag (e.g., -v, -run, -count), it is passed to the test binary. For clarity and to prevent surprises from implicit argument passing, we can use -args to pass arguments to the test binary explicitly. For example:

 1var foo = flag.Int("foo", 0, "test flag")
 2var count = flag.Int("count", 0, "test flag")
 3
 4func TestFoo(t *testing.T) {
 5    assert.Equal(t, 1, *foo)
 6}
 7
 8func TestCount(t *testing.T) {
 9    assert.Equal(t, 1, *count)
10}
 1# PASS: -foo is not a testing flag and thus passed to TestFoo implicitly.
 2go test -run TestFoo -count=1 -v -foo=1 
 3
 4# PASS: -foo is passed to TestFoo explicitly.
 5go test -run TestFoo -count=1 -v -args -foo=1
 6
 7# PASS: -count is passed to TestCount explicitly.
 8go test -run TestCount -count=1 -v -args -count=1
 9
10# FAIL. -count is a testing flag that sets number of times to run each test.
11go test -run TestCount -count=1 -v -count=1

The "-args" is a flag for the "go test" command. If we compile the test binary and run it manually, then "-args" doesn't apply. And testing flags must be prefixed with -test..

 1go test -c -o hellotest
 2./hellotest -test.v -test.count=2 -foo=1 -count=1
 3=== RUN   TestFoo
 4--- PASS: TestFoo (0.00s)
 5=== RUN   TestCount
 6--- PASS: TestCount (0.00s)
 7=== RUN   TestFoo
 8--- PASS: TestFoo (0.00s)
 9=== RUN   TestCount
10--- PASS: TestCount (0.00s)
11PASS

The modernize tool

The modernize tool simplifies code by using modern constructs. To use it on a Go project, it's as simple as:

1go install golang.org/x/tools/gopls/internal/analysis/modernize/cmd/modernize@latest
2
3modernize -fix -test ./...

Remove unnecessary x := x declarations in for loops

Before Go 1.22, the variables declared by a "for" loop were created once and updated by each iteration. In Go 1.22, each iteration of the loop creates new variables to avoid accidental sharing bugs. Learn more about the decision in Fixing For Loops in Go 1.22.

 1for _, foo := range foos {
 2       foo := foo // <-- unnecessary
 3}
 4
 5for _, tc := range testCases {
 6    tc := tc // <-- unnecessary 
 7    t.Run(tc.name, func(t *testing.T) {
 8        tc := tc // <-- unnecessary
 9        t.Parallel()
10    }) 
11} 

More modern Go constructs by Go version

Go 1.24

  1. Replace uses of context.WithCancel in tests with t.Context.
 1// Replace
 2func TestFoo(t *testing.T) {
 3    ctx, cancel := context.WithCancel()
 4    defer cancel()
 5    foo(ctx)
 6}
 7// by
 8func TestFoo(t *testing.T) {
 9    foo(t.Context())
10}
  1. Replace Split with more efficient SplitSeq, or Fields with FieldSeq.
1// Replace
2s := "hello===world===!"
3for _, token := range strings.Split(s, "===") {
4    fmt.Println(token)
5}
6// by
7for token := range strings.SplitSeq(s, "===") {
8    fmt.Println(token)
9}

Go 1.22

  1. “For” loops may now range over integers. Replace a 3-clause "for i := 0; i < n; i++" loop by "for i := range n".

Go 1.21

  1. The new slices package For example,
 1// Replace 
 2found := false
 3for i, elem := range s { 
 4     if elem == needle {
 5        found = true
 6       break 
 7    }
 8}
 9// by 
10found := slices.Contains(s, needle)

There are many other handy functions on slices. For example, slices.ContainsFunc, slices.DeleteFunc.

  1. The built-in min and max functions simplify conditional assignments. For example,
1// Replace 
2cpuBurst := task.InitialBurstQuotaCPU
3if cpuBurst < int64(task.CPU * vcpu) {
4    cpuBurst = int64(task.CPU * vcpu)
5}
6// by
7cpuBurst := max(task.InitialBurstQuotaCPU, int64(task.CPU * vcpu))
  1. The maps package provides several common operations on maps.
1// Replace
2for name, value := range envs {
3    c.Environment[name] = value
4}
5// by
6maps.Copy(c.Environment, envs)

Go 1.18

Go 1.18 introduced a predefined identifier "any" as an alias for the empty interface.

1// Replace
2var m map[string]interface{}
3// by
4var m map[string]any