Modern Go idioms
Overview
Go is known for its backward compatibility, simplicity, and six-month release cycle. But that can sometimes lead to code that works yet isn't as modern as it could be. This post is a living document where I note modern Go idioms I've used 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, so 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 _test.go) matches any of these patterns: *_GOOS, *_GOARCH, or *_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.
Using go fix to modernize Go code
go fix applies fixes suggested by static checkers. You can list available analyzers with go tool fix help. Each analyzer corresponds to a flag. For example, go fix -buildtag converts the pre-Go 1.17 build directive syntax // +build to //go:build. Learn more from Using go fix to modernize Go code
. In your Makefile, add go-fix-check and go-fix targets. Run go-fix-check in CI/CD to prevent unfixed code from merging, and use go-fix for local development.
1GO_FIX_PKGS = $(shell go list ./... | grep -v \
2 -e '/some/autogenerated/package/like/mocks')
3
4.PHONY: go-fix-check
5go-fix-check:
6# go fix doesn't have an option to exit non-zero; use grep on diff output instead
7 @if go fix -diff $(GO_FIX_PKGS) 2>&1 | grep -q '^---'; then \
8 echo "go fix changes needed; run 'make go-fix' to apply them"; exit 1; \
9 fi
10
11.PHONY: go-fix
12go-fix:
13# intentionally run go fix twice for Synergistic fixes
14# https://go.dev/blog/gofix#synergistic-fixes
15 go fix $(GO_FIX_PKGS)
16 go fix $(GO_FIX_PKGS)
Deterministic import order
gci controls Go package import order and makes it always deterministic. In Makefile, you can use gci-check in CI/CD and gci for local development.
1.PHONY: gci-check
2gci-check:
3 gci diff -s standard -s "prefix(your/module/package/prefix)" -s default --custom-order --skip-generated --skip-vendor . || (echo "gci changes needed; run 'make gci' to apply them" && exit 1)
4
5.PHONY: gci
6gci:
7 gci write -s standard -s "prefix(your/module/package/prefix)" -s default --custom-order --skip-generated --skip-vendor .
Use golangci-lint
golangci-lint runs a configurable list of linters defined in .golangci.yml. It's the most popular Go linter runner. While golangci-lint itself doesn't require the Go SDK to run, some of its linters may shell out to the Go toolchain on the host, which can cause unexpected issues. For example, running golangci-lint v1.64.8 with Go 1.26 caused timeouts and high memory usage; upgrading to v2.9.0 fixed it. Always use the latest golangci-lint v2. You can migrate a v1 config to v2 using golangci-lint migrate.
Use "go test -args" to pass arguments to tests
Go test files don't call flag.Parse() — 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 avoid surprises from implicit argument passing, 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 is 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 the number of times to run each test.
11go test -run TestCount -count=1 -v -count=1
-args is a flag for the go test command. If you compile the test binary and run it manually, -args doesn't apply.
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
Remove unnecessary x := x declarations in for loops
Before Go 1.22, variables declared by a for loop were created once and updated by each iteration. In Go 1.22, each iteration creates new variables, eliminating accidental sharing bugs.
Learn more 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}
Modern Go constructs by Go version
Go 1.26
newnow accepts an expression as its operand. This is handy when you need a pointer to a value inline, such as in JSON structs.
1// Replace
2v := SomeValue{Field: "x"}
3p := &v
4// by
5p := new(SomeValue{Field: "x"})
- Replace
errors.Aswith the generic errors.AsType for a cleaner, type-safe alternative.
1// Replace
2var e *MyError
3if errors.As(err, &e) {
4 fmt.Println(e.Code)
5}
6// by
7if e, ok := errors.AsType[*MyError](err); ok {
8 fmt.Println(e.Code)
9}
Go 1.25
-
The runtime now automatically respects cgroup CPU limits on Linux, so
GOMAXPROCSis set correctly in containers without needing automaxprocs. If you're using that library, you can remove it. -
Replace manual goroutine spawning inside
sync.WaitGroupblocks with the new WaitGroup.Go method.
1// Replace
2var wg sync.WaitGroup
3wg.Add(1)
4go func() {
5 defer wg.Done()
6 doWork()
7}()
8wg.Wait()
9// by
10var wg sync.WaitGroup
11wg.Go(doWork)
12wg.Wait()
Go 1.24
- Replace
context.WithCancelin 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
Splitwith the more efficientSplitSeq, orFieldswithFieldSeq.
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
forloops can now range over integers. Replace a 3-clausefor i := 0; i < n; i++loop withfor 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 in the package, such as slices.ContainsFunc and slices.DeleteFunc.
- The built-in
minandmaxfunctions 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 map operations.
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 any as a predefined alias for the empty interface.
1// Replace
2var m map[string]interface{}
3// by
4var m map[string]any