Stop Writing Go Like It’s 2017: 15 Modern Patterns You Should Be Using

Your code compiles. But is it modern Go? Here’s how to bridge the gap.

分享
Stop Writing Go Like It’s 2017: 15 Modern Patterns You Should Be Using
Cover image generated by AI

· Background
· The Foundation: Go 1.8 to 1.13 — Time and Error Handling
 ∘ time.Since And time.Until (Go 1.0+ and 1.8+)
 ∘ errors.Is For Wrapped Errors (Go 1.13+)
· The Generics Revolution: Go 1.18
 ∘ any Instead of interface{}
 ∘ strings.Cut and bytes.Cut — String Parsing Made Simple
· Atomic Operations Get Type-Safe: Go 1.19
· String and Byte Utilities: Go 1.20
 ∘ strings.Clone And bytes.Clone
 ∘ strings.CutPrefix And strings.CutSuffix
 ∘ errors.Join — Combining Multiple Errors
· The Standard Library Renaissance: Go 1.21
 ∘ Built-ins: min, max, and clear
 ∘ The slices Package
 ∘ The maps Package
 ∘ sync.OnceFunc And sync.OnceValue
· Loop Revolution: Go 1.22
 ∘ for i := range n — Integer Range
 ∘ Fixed Loop Variable Capture
 ∘ cmp.Or — First Non-Zero Value
 ∘ Enhanced http.ServeMux
· Iterator Pattern: Go 1.23
 ∘ time.Tick Is Now Safe
· Testing and JSON Improvements: Go 1.24
 ∘ t.Context() In Tests
 ∘ omitzero For JSON
 ∘ b.Loop() For Benchmarks
 ∘ strings.SplitSeq For Iteration
· Summary
· Tools to Help You Modernize
· A Word of Caution

Background

A few weeks ago, I stumbled upon something interesting: JetBrains’ Go Modern Guidelines. It’s a collection of patterns and features that transform legacy Go code into modern, idiomatic Go. That got me thinking — how many of us are still writing Go the way we learned it years ago?

I was reviewing a colleague’s pull request and noticed this pattern:

// The old way - still works, but... 
for i := 0; i < len(items); i++ { 
    process(items[i]) 
}

This is valid Go. It compiles. It runs. But it’s not modern Go. Since Go 1.8, the language has evolved dramatically, adding features that make code more readable, more maintainable, and often more performant. Yet many developers — especially those who learned Go years ago — continue writing code the way they always have.

This isn’t about being pedantic. It’s about leverage. Each new feature in Go solves real problems, reduces boilerplate, and makes intent clearer. In this article, we’ll explore the most impactful changes from Go 1.8 through Go 1.24, with before-and-after examples you can apply today.

The Foundation: Go 1.8 to 1.13 — Time and Error Handling

Let’s start with two simple but frequently used improvements that have been available for years.

time.Since And time.Until (Go 1.0+ and 1.8+)

Measuring elapsed time is incredibly common. The modern approach is cleaner:

// Before: Verbose and error-prone 
start := time.Now() 
doWork() 
elapsed := time.Now().Sub(start)  // Easy to get wrong! 
// After: Clean and idiomatic 
start := time.Now() 
doWork() 
elapsed := time.Since(start)

Similarly, time.Until (Go 1.8+) replaces deadline.Sub(time.Now()):

// Before 
timeToDeadline := deadline.Sub(time.Now()) 
// After 
timeToDeadline := time.Until(deadline)

Isn’t it fascinating how such small changes add up to cleaner code?

errors.Is For Wrapped Errors (Go 1.13+)

Error wrapping with fmt.Errorf("%w", err) was a game-changer in Go 1.13. But it introduced a problem: you can no longer use == to check error types. The solution is errors.Is:

// Before: direct equality becomes brittle once errors may be wrapped 
if err == fs.ErrNotExist { 
    // This may fail if the error was wrapped 
} 
 
// After: works across the error chain 
if errors.Is(err, fs.ErrNotExist) { 
    // Correctly handles wrapped sentinel errors 
}
Updated: The original example used io.EOF, which is a special-case sentinel in Go and should not be wrapped. The example now uses fs.ErrNotExist instead.
Error wrapping with fmt.Errorf("%w", err) was a game-changer in Go 1.13. Once errors may be wrapped, direct == checks become brittle for sentinel errors in general. The recommended approach is to use errors.Is, which walks the error chain.
io.EOF is a notable exception: by convention it should be returned directly rather than wrapped, since callers often compare it using ==.
Related discussion: https://github.com/golang/go/issues/39155
thanks Paul Hewlett

The key insight: errors.Is traverses the entire error chain, checking each wrapped error. This is crucial when working with libraries that wrap errors for context.

The Generics Revolution: Go 1.18

Go 1.18 was a watershed moment. Generics arrived, and with them, a cleaner way to write type-agnostic code.

any Instead of interface{}

This is the simplest change with the biggest visual impact:

// Before: Verbose and ugly 
func process(data interface{}) interface{} 
// After: Clean and expressive 
func process(data any) any

They’re identical under the hood — any is just a type alias for interface{}. But the intent is clearer, and your code reads better.

strings.Cut and bytes.Cut — String Parsing Made Simple

How many times have you written this pattern?

// Before: Index + slice gymnastics 
idx := strings.Contains(s, "=") 
if idx != -1 { 
    key := s[:idx] 
    value := s[idx+1:] 
}

strings.Cut (Go 1.18) replaces this with a single, expressive call:

// After: Clean and safe 
key, value, found := strings.Cut(s, "=") 
if found { 
    // Use key and value directly 
}

This is particularly elegant for parsing key-value pairs, URLs, or any delimited data. The same pattern applies to bytes.Cut for byte slices.

Atomic Operations Get Type-Safe: Go 1.19

The sync/atomic package received a major upgrade in Go 1.19, introducing type-safe atomic types. No more unsafe.Pointer gymnastics or int32 conversions!

// Before: Verbose and error-prone 
var flag int32 
atomic.StoreInt32(&flag, 1) 
if atomic.LoadInt32(&flag) == 1 { 
    // ... 
} 
// After: Clean and type-safe 
var flag atomic.Bool 
flag.Store(true) 
if flag.Load() { 
    // ... 
}

The new atomic types include:

  • atomic.Bool
  • atomic.Int32, atomic.Int64, atomic.Uint32, atomic.Uint64
  • atomic.Pointer[T] — Type-safe pointers!

The atomic.Pointer[T] is especially powerful for lock-free data structures:

var config atomic.Pointer[Config] 
// Store a new config 
newCfg := &Config{Timeout: 30 * time.Second} 
config.Store(newCfg) 
// Load atomically 
current := config.Load() 
fmt.Println(current.Timeout)

String and Byte Utilities: Go 1.20

Go 1.20 added several utilities that eliminate common boilerplate.

strings.Clone And bytes.Clone

Sometimes you need to ensure a string or byte slice doesn’t share underlying memory. Previously, this required manual copying:

// Before: Manual copy 
cloned := make([]byte, len(original)) 
copy(cloned, original) 
// After: Expressive and clear 
cloned := bytes.Clone(original)

strings.CutPrefix And strings.CutSuffix

These are perfect for parsing tasks:

// Before: Manual prefix check and slice 
if strings.HasPrefix(s, "prefix:") { 
    rest := s[len("prefix:"):] 
    // ... 
} 
// After: Single operation 
if rest, ok := strings.CutPrefix(s, "prefix:"); ok { 
    // rest contains everything after "prefix:" 
}

errors.Join — Combining Multiple Errors

When you have multiple errors to return, errors.Join (Go 1.20) provides a clean solution:

// Before: Return first error only, or custom error type 
var errs []error 
if err1 != nil { 
    errs = append(errs, err1) 
} 
if err2 != nil { 
    errs = append(errs, err2) 
} 
return errors.New("multiple errors occurred") // Lost details! 
// After: Preserve all errors 
return errors.Join(err1, err2, err3)

The joined error implements Unwrap() []error, so errors.Is still works correctly.

The Standard Library Renaissance: Go 1.21

Go 1.21 was a massive release, introducing built-in functions and new packages that eliminate huge amounts of boilerplate.

Built-ins: min, max, and clear

These functions work with any ordered type:

// Before: Manual comparison 
if a > b { 
    result = a 
} else { 
    result = b 
} 
// After: Clean built-in 
result := max(a, b) 
// Works with multiple arguments too! 
result := max(a, b, c, d, e)

The clear built-in deletes all map entries or zeros slice elements:

// Clear all map entries 
m := map[string]int{"a": 1, "b": 2} 
clear(m)  // m is now empty 
// Zero slice elements 
s := []int{1, 2, 3, 4, 5} 
clear(s)  // s is now []int{0, 0, 0, 0, 0}

The slices Package

This package is a game-changer for slice operations. Let’s look at the most useful functions:

// Contains - no more manual loops! 
if slices.Contains(items, target) { 
    // Found it 
} 
// Index - find position (-1 if not found) 
pos := slices.Index(items, target) 
// Sort - for ordered types 
slices.Sort(numbers) 
// SortFunc - for custom types 
slices.SortFunc(users, func(a, b User) int { 
    return cmp.Compare(a.Age, b.Age) 
}) 
// Max/Min - find extremes 
oldest := slices.Max(ages) 
// Reverse - in-place reversal 
slices.Reverse(items) 
// Clone - create a copy 
backup := slices.Clone(items) 
// Compact - remove consecutive duplicates 
items = slices.Compact(items)  // [1,1,2,2,3] → [1,2,3]

The maps Package

Similarly, the maps package simplifies map operations:

// Clone a map 
copy := maps.Clone(original) 
// Copy entries from one map to another 
maps.Copy(destination, source) 
// Delete entries matching a condition 
maps.DeleteFunc(m, func(k string, v int) bool { 
    return v < 0  // Remove negative values 
})

sync.OnceFunc And sync.OnceValue

These eliminate the boilerplate of sync.Once:

// Before: Verbose initialization 
var ( 
    once sync.Once 
    config *Config 
) 
func GetConfig() *Config { 
    once.Do(func() { 
        config = loadConfig() 
    }) 
    return config 
} 
// After: Clean and direct 
var getConfig = sync.OnceValue(func() *Config { 
    return loadConfig() 
}) 
// Usage 
cfg := getConfig()

sync.OnceFunc is similar but for side effects:

var initDB = sync.OnceFunc(func() { 
    db = sql.Open("postgres", dsn) 
}) 
// Call multiple times, but only executes once 
initDB()

Loop Revolution: Go 1.22

Go 1.22 introduced two of the most-requested features: cleaner integer loops and fixed loop-variable semantics.

for i := range n — Integer Range

This is the change that will clean up your code the most:

// Before: Verbose C-style loop 
for i := 0; i < len(items); i++ { 
    process(items[i]) 
} 
// After: Clean and expressive 
for i := range len(items) { 
    process(items[i]) 
} 
// Even simpler for just counting 
for i := range 10 { 
    fmt.Println(i)  // 0, 1, 2, ..., 9 
}

That’s pretty cool! No more i++ bugs or off-by-one errors.

Fixed Loop Variable Capture

This was a notorious Go gotcha that’s finally fixed:

// Before: Bug! All goroutines see the same value 
for _, item := range items { 
    go func() { 
        process(item)  // Wrong! All see the last item 
    }() 
} 
// Old workaround: Pass as argument 
for _, item := range items { 
    go func(item Item) { 
        process(item) 
    }(item) 
} 
// After (Go 1.22+): Just works! 
for _, item := range items { 
    go func() { 
        process(item)  // Each goroutine gets its own copy 
    }() 
}

In Go 1.22+, each iteration creates new variables, eliminating this entire class of bugs.

cmp.Or — First Non-Zero Value

This is perfect for configuration with defaults:

// Before: Verbose fallback chain 
name := os.Getenv("NAME") 
if name == "" { 
    name = config.Name 
    if name == "" { 
        name = "default" 
    } 
} 
// After: Clean and linear 
name := cmp.Or( 
    os.Getenv("NAME"), 
    config.Name, 
    "default", 
)

cmp.Or returns the first non-zero value for any comparable type.

Enhanced http.ServeMux

The standard library’s HTTP router got a major upgrade:

// Before: Manual method checking and path parsing 
mux.HandleFunc("/api/items", func(w http.ResponseWriter, r *http.Request) { 
    if r.Method != "GET" { 
        http.Error(w, "Method not allowed", 405) 
        return 
    } 
    // Extract ID from path manually... 
}) 
// After: Method and path parameters built-in 
mux.HandleFunc("GET /api/items/{id}", func(w http.ResponseWriter, r *http.Request) { 
    id := r.PathValue("id")  // Clean extraction 
    // ... 
})

This alone can eliminate the need for many third-party routers in simple applications.

Iterator Pattern: Go 1.23

Go 1.23 introduced iterators, enabling a new pattern for collections. The maps and slices packages were updated to support them.

// Iterate over map keys directly 
for k := range maps.Keys(m) { 
    fmt.Println(k) 
} 
// Collect keys to a slice 
keys := slices.Collect(maps.Keys(m)) 
// Collect and sort in one operation 
sortedKeys := slices.Sorted(maps.Keys(m))

The iterator pattern is particularly powerful for custom collection types, allowing them to work seamlessly with for...range loops.

time.Tick Is Now Safe

Previously, time.Tick leaked goroutines if the ticker wasn't stopped. As of Go 1.23, the garbage collector can reclaim unreferenced tickers:

// Before: Had to use NewTicker to avoid leak 
ticker := time.NewTicker(time.Second) 
defer ticker.Stop() 
for t := range ticker.C { 
    // ... 
} 
// After (Go 1.23+): Tick is fine for simple cases 
for t := range time.Tick(time.Second) { 
    // Safe! GC handles cleanup 
}

Testing and JSON Improvements: Go 1.24

Go 1.24 continues the trend of reducing boilerplate.

t.Context() In Tests

No more manual context creation in tests:

// Before: Verbose setup 
func TestFeature(t *testing.T) { 
    ctx, cancel := context.WithCancel(context.Background()) 
    defer cancel() 
    result := doSomething(ctx) 
    // ... 
} 
// After: Clean and automatic 
func TestFeature(t *testing.T) { 
    result := doSomething(t.Context()) 
    // Automatically cancelled when test ends 
}

The test’s context is automatically cancelled when the test completes, including during timeouts or parallel execution.

omitzero For JSON

The omitempty tag has a long-standing issue: it doesn't work for time.Duration or structs. omitzero (Go 1.24) fixes this:

// Before: Duration zero values still appear in JSON 
type Config struct { 
    Timeout time.Duration `json:"timeout,omitempty"`  // Doesn't work! 
} 
// After: Proper zero-value handling 
type Config struct { 
    Timeout time.Duration `json:"timeout,omitzero"`  // Works correctly 
}

Use omitzero for:

  • time.Duration and time.Time
  • Structs
  • Slices and maps

b.Loop() For Benchmarks

Benchmarks get cleaner too:

// Before: Manual loop counter 
func BenchmarkWork(b *testing.B) { 
    for i := 0; i < b.N; i++ { 
        doWork() 
    } 
} 
// After: Expressive loop 
func BenchmarkWork(b *testing.B) { 
    for b.Loop() { 
        doWork() 
    } 
}

b.Loop() handles the iteration count automatically and can provide more accurate measurements by managing timer state internally.

strings.SplitSeq For Iteration

When iterating over split results, use the new sequence functions:

// Before: Creates intermediate slice 
for _, part := range strings.Split(s, ",") { 
    process(part) 
} 
// After: Iterates without allocation 
for part := range strings.SplitSeq(s, ",") { 
    process(part) 
}

This avoids creating the intermediate slice, which can be significant for large strings or frequent operations. The same pattern exists for strings.FieldsSeq, bytes.SplitSeq, and bytes.FieldsSeq.

Summary

Go has evolved significantly since 1.8. Here’s a quick reference of the changes we covered:

  1. Go 1.8+: time.Until for deadline calculations
  2. Go 1.13+: errors.Is for wrapped error checking
  3. Go 1.18+: any alias, strings.Cut, bytes.Cut, generics
  4. Go 1.19+: Type-safe atomics (atomic.Bool, atomic.Pointer[T])
  5. Go 1.20+: strings.Clone, strings.CutPrefix, errors.Join
  6. Go 1.21+: min/max/clear built-ins, slices and maps packages, sync.OnceFunc
  7. Go 1.22+: for i := range n loops, fixed loop variables, cmp.Or, enhanced http.ServeMux
  8. Go 1.23+: Iterator pattern with maps.Keys, slices.Collect, safe time.Tick
  9. Go 1.24+: t.Context(), omitzero JSON tag, b.Loop(), strings.SplitSeq

Each of these features solves real problems and reduces boilerplate. The cumulative effect is code that’s more readable, more maintainable, and less prone to bugs.

Tools to Help You Modernize

If you’re wondering how to apply these patterns consistently across your codebase, check out JetBrains’ Go Modern Guidelines. It’s a collection of inspections and quick fixes that can automatically transform legacy Go code into modern, idiomatic Go.

The project is particularly useful for:

  • Code reviews: Spot outdated patterns during PR reviews
  • Legacy migrations: Systematically update older codebases
  • Team consistency: Ensure everyone on your team writes modern Go

While tools like this are helpful, understanding why these patterns matter is still essential. Modern Go isn’t just about syntax — it’s about writing code that’s clearer, safer, and more maintainable.

A Word of Caution

While modern features are powerful, consider your team’s Go version. Check your go.mod file:

grep "^go " go.mod

Only use features available in your target version. If you’re supporting older Go versions, tools like golangci-lint with version-aware rules can help catch incompatible code.

That said, if you’re on Go 1.21 or later (and you should be — 1.20 is already EOL), most of these features are available today. Start using them. Your future self will thank you.