Go Evolution: Version Changes You Should Know

Stop Writing Go Like It’s 2018 — The Before/After Patterns That Define Idiomatic Go Today

image.png|300

1. Introduction: Why Even AI Needs a Version Guide

JetBrains recently published go-modern-guidelines — a ruleset built specifically so AI code assistants write idiomatic Go based on the project’s actual Go version. The motivation is real: the idiomatic way to write the same logic in Go 1.13, Go 1.21, and Go 1.24 is genuinely different. If your AI (or your teammate) doesn’t know which version you’re targeting, it will write code that compiles but is quietly outdated.

This prompted me to go through the guidelines carefully and extract the patterns that matter most. This article is organized as a series of before/after pairs — the old idiom vs. the modern replacement — grouped by theme. If you’ve been writing Go for a few years, some of these will be familiar. Others might surprise you.

2. The any Alias: Small Change, Big Signal (1.18)

Go 1.18 shipped generics — the biggest language addition in Go’s history — but it also quietly introduced one of the most immediately actionable changes for everyday code: any as a built-in alias for interface{}.

1
2
3
4
5
6
7
// Before (Go 1.17 and earlier)
func Print(v interface{}) { fmt.Println(v) }
var cache map[string]interface{}

// After (Go 1.18+) — any is identical to interface{}, just cleaner
func Print(v any) { fmt.Println(v) }
var cache map[string]any

Note that any is a true alias — interface{} and any are interchangeable at the type level, so adopting it requires no migration, only a habit change. Most modern Go codebases and the standard library itself now use any exclusively.

3. Error Handling Grew Up

Error handling is one of the areas where Go’s idiomatic style has changed most visibly.

Go 1.13 — Stop comparing errors with ==

Before errors.Is, checking for specific errors meant direct equality comparison, which breaks the moment an error gets wrapped:

1
2
3
4
5
6
7
8
9
// Before (Go 1.12 and earlier) — breaks with wrapped errors
if err == os.ErrNotExist {
// ...
}

// After (Go 1.13+) — works through any wrapping chain
if errors.Is(err, os.ErrNotExist) {
// ...
}

errors.Is unwraps the error chain automatically. Any library that wraps errors with fmt.Errorf("context: %w", err) will now work correctly with your checks.

Go 1.20 — Combining multiple errors

Before errors.Join, aggregating multiple errors required either picking one or writing custom error types:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Before — losing information
func validate(cfg Config) error {
var errs []error
if cfg.Host == "" { errs = append(errs, errors.New("missing host")) }
if cfg.Port == 0 { errs = append(errs, errors.New("missing port")) }
if len(errs) > 0 { return errs[0] } // only first error survives
return nil
}

// After (Go 1.20+) — all errors preserved, each extractable via errors.Is/As
func validate(cfg Config) error {
var errs []error
if cfg.Host == "" { errs = append(errs, errors.New("missing host")) }
if cfg.Port == 0 { errs = append(errs, errors.New("missing port")) }
return errors.Join(errs...)
}

Go 1.26 — Type-asserting errors without a temporary variable

1
2
3
4
5
6
7
8
9
10
// Before
var pathErr *os.PathError
if errors.As(err, &pathErr) {
fmt.Println(pathErr.Path)
}

// After (Go 1.26+) — cleaner, no declaration needed
if pathErr, ok := errors.AsType[*os.PathError](err); ok {
fmt.Println(pathErr.Path)
}

4. String and Bytes Operations

Go 1.18 — strings.Cut replaces Index+slice

One of the most commonly hand-rolled operations in Go code is splitting a string on a separator and taking the part before or after it. strings.Cut makes this a single call:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Before — verbose, easy to get off-by-one
s := "user:password"
idx := strings.Index(s, ":")
if idx >= 0 {
user, pass := s[:idx], s[idx+1:]
_, _ = user, pass
}

// After (Go 1.18+)
user, pass, found := strings.Cut(s, ":")
if found {
_, _ = user, pass
}

The same API is available on bytes.Cut for byte slices.

Go 1.20 — Trimming known prefixes and suffixes

1
2
3
4
5
6
7
8
9
10
// Before
if strings.HasPrefix(s, "Bearer ") {
token := strings.TrimPrefix(s, "Bearer ")
_ = token
}

// After (Go 1.20+)
if token, ok := strings.CutPrefix(s, "Bearer "); ok {
_ = token
}

Go 1.24 — Iterating over split results without allocating a slice

1
2
3
4
5
6
7
8
9
// Before — allocates []string just to iterate
for _, part := range strings.Split(csv, ",") {
process(part)
}

// After (Go 1.24+) — lazy iterator, no intermediate allocation
for part := range strings.SplitSeq(csv, ",") {
process(part)
}

strings.FieldsSeq, bytes.SplitSeq, and bytes.FieldsSeq follow the same pattern.

5. Built-in Ergonomics: min, max, clear, and Range

Go 1.21 — min, max, clear are now built-in

These three operations were so common that Go finally made them first-class:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Before
func maxInt(a, b int) int {
if a > b { return a }
return b
}
smallest := math.MinInt
for _, v := range values { if v < smallest { smallest = v } }

// After (Go 1.21+)
smallest := min(values...)
largest := max(a, b, c)

// clear replaces delete-all loops
for k := range m { delete(m, k) } // old
clear(m) // new — also works on slices (zeros elements)

Go 1.22 — Range over integers

1
2
3
4
5
6
7
8
9
// Before
for i := 0; i < 10; i++ {
fmt.Println(i)
}

// After (Go 1.22+)
for i := range 10 {
fmt.Println(i)
}

Go 1.22 — The loop variable capture bug is fixed

This was Go’s most notorious footgun for years. Every Go developer has hit this at least once:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Before Go 1.22 — all goroutines capture the same variable
for _, item := range items {
go func() {
process(item) // bug: all goroutines see the last item
}()
}

// The "fix" that was required pre-1.22
for _, item := range items {
item := item // shadow the variable
go func() {
process(item) // now each goroutine has its own copy
}()
}

// After Go 1.22 — just works, no shadowing needed
for _, item := range items {
go func() {
process(item) // correct: each iteration has its own item
}()
}

Note that this change applies automatically to code with go 1.22 or later in go.mod. Old code with go 1.21 or earlier retains the old behavior.

6. The slices and maps Packages

Go 1.21 promoted slices and maps to the standard library, replacing a whole category of hand-written utility loops.

Common slice operations

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Before — manual loops everywhere
func contains(items []string, x string) bool {
for _, v := range items { if v == x { return true } }
return false
}
func indexOf(items []string, x string) int {
for i, v := range items { if v == x { return i } }
return -1
}
sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name })

// After (Go 1.21+)
slices.Contains(items, x)
slices.Index(items, x)
slices.SortFunc(items, func(a, b Item) int { return cmp.Compare(a.Name, b.Name) })
slices.Reverse(items)
slices.Compact(items) // remove consecutive duplicates
clone := slices.Clone(items)

Map utilities

1
2
3
4
5
6
7
8
// Before
clone := make(map[string]int, len(m))
for k, v := range m { clone[k] = v }

// After (Go 1.21+)
clone := maps.Clone(m)
maps.Copy(dst, src)
maps.DeleteFunc(m, func(k string, v int) bool { return v == 0 })

Go 1.22 — cmp.Or for first-non-zero fallback chains

1
2
3
4
5
6
7
// Before — the classic "or empty string" chain
name := os.Getenv("APP_NAME")
if name == "" { name = cfg.Name }
if name == "" { name = "default" }

// After (Go 1.22+)
name := cmp.Or(os.Getenv("APP_NAME"), cfg.Name, "default")

Go 1.23 — maps.Keys and maps.Values return iterators

1
2
3
4
5
6
7
8
9
10
11
12
13
// Before — materializes a full slice just to sort keys
keys := make([]string, 0, len(m))
for k := range m { keys = append(keys, k) }
sort.Strings(keys)

// After (Go 1.23+) — compose directly
keys := slices.Collect(maps.Keys(m))
sortedKeys := slices.Sorted(maps.Keys(m))

// Or iterate without ever building a slice
for k := range maps.Keys(m) {
process(k)
}

7. Concurrency Primitives

Go 1.19 — Type-safe atomics

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Before — untyped, easy to mix up Store/Load/Add on wrong types
var flag int32
atomic.StoreInt32(&flag, 1)
if atomic.LoadInt32(&flag) == 1 { ... }

// After (Go 1.19+) — typed, method-based
var flag atomic.Bool
flag.Store(true)
if flag.Load() { ... }

var counter atomic.Int64
counter.Add(1)

var cfg atomic.Pointer[Config]
cfg.Store(newConfig)
current := cfg.Load()

Go 1.20 — Cancellation with a cause

1
2
3
4
5
6
7
8
9
10
11
// Before — you know the context was cancelled, but not why
ctx, cancel := context.WithCancel(parent)
defer cancel()

// After (Go 1.20+) — attach a specific error as the reason
ctx, cancel := context.WithCancelCause(parent)
cancel(ErrShutdownRequested)
// Later:
if cause := context.Cause(ctx); cause != nil {
log.Printf("cancelled because: %v", cause)
}

Go 1.21 — sync.OnceFunc and sync.OnceValue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Before — boilerplate every time you want single-initialization
var (
once sync.Once
instance *Service
)
func getInstance() *Service {
once.Do(func() { instance = newService() })
return instance
}

// After (Go 1.21+)
getInstance := sync.OnceValue(func() *Service {
return newService()
})
// Call getInstance() as many times as you want

Go 1.25 — wg.Go replaces the Add/Done dance

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Before — three lines of ceremony per goroutine
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
go func() {
defer wg.Done()
process(item)
}()
}
wg.Wait()

// After (Go 1.25+)
var wg sync.WaitGroup
for _, item := range items {
wg.Go(func() {
process(item)
})
}
wg.Wait()

8. Testing and Benchmarking

Go 1.24 — t.Context() in tests

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Before — manual context management in every test that needs one
func TestFetchUser(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
user, err := fetchUser(ctx, 42)
// ...
}

// After (Go 1.24+) — context tied to test lifetime automatically
func TestFetchUser(t *testing.T) {
ctx := t.Context() // cancelled when the test ends
user, err := fetchUser(ctx, 42)
// ...
}

Go 1.24 — b.Loop() in benchmarks

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Before — b.N loop, easy to accidentally include setup in the measured path
func BenchmarkEncode(b *testing.B) {
data := generateData()
for i := 0; i < b.N; i++ {
encode(data)
}
}

// After (Go 1.24+) — cleaner, more accurate
func BenchmarkEncode(b *testing.B) {
data := generateData()
for b.Loop() {
encode(data)
}
}

Go 1.24 — omitzero in JSON struct tags

1
2
3
4
5
6
7
8
9
10
11
12
13
// Before — omitempty doesn't work correctly for time.Duration, time.Time, or structs
type Event struct {
Name string `json:"name"`
Timeout time.Duration `json:"timeout,omitempty"` // bug: 0 is valid, omitempty omits it
At time.Time `json:"at,omitempty"` // bug: zero time is omitted but unexpectedly
}

// After (Go 1.24+) — omitzero uses the type's IsZero() method
type Event struct {
Name string `json:"name"`
Timeout time.Duration `json:"timeout,omitzero"`
At time.Time `json:"at,omitzero"`
}

9. Go 1.26: Eliminating the Pointer-to-Value Boilerplate

One of the most common complaints in Go API design is how to pass a pointer to a literal value. new only accepted types, not expressions — forcing a temporary variable.

1
2
3
4
5
6
7
8
9
10
11
12
13
// Before — a temporary variable just to get a pointer
timeout := 30 * time.Second
debug := true
cfg := Config{
Timeout: &timeout,
Debug: &debug,
}

// After (Go 1.26+) — new() accepts expressions, type is inferred
cfg := Config{
Timeout: new(30 * time.Second), // *time.Duration
Debug: new(true), // *bool
}

10. Conclusion

Reading through the JetBrains go-modern-guidelines is a useful exercise because it surfaces a pattern that’s easy to miss when you’re living inside a codebase: Go doesn’t add complexity, it removes boilerplate. Each major release takes something developers were manually writing and promotes it into a cleaner built-in form.

strings.Cut eliminated index arithmetic. errors.Join eliminated manual error aggregation. slices.Contains eliminated search loops. wg.Go eliminated the Add/Done ceremony. In each case, the old code still compiles — but the new version is shorter, less error-prone, and clearer about intent.

The practical takeaway: check your go.mod version and then audit your codebase for patterns on this list. Each one you update is a small improvement in readability and a reduction in surface area for bugs.

Which of these surprised you the most? Are there patterns in your codebase that you’ve been writing the hard way that I missed? Share in the comments!

AI-Native Engineering: Inside Boris Cherny's Claude Code Workflow Go's Standard Library: The Production-Ready Arsenal Behind Its Success

Comments

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×