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
- 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}
- 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
- “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
- 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.
- 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))
- 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