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.
· 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 usedio.EOF, which is a special-case sentinel in Go and should not be wrapped. The example now usesfs.ErrNotExistinstead.
Error wrapping withfmt.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 useerrors.Is, which walks the error chain.
io.EOFis 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) anyThey’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.Boolatomic.Int32,atomic.Int64,atomic.Uint32,atomic.Uint64atomic.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.Durationandtime.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:
- Go 1.8+:
time.Untilfor deadline calculations - Go 1.13+:
errors.Isfor wrapped error checking - Go 1.18+:
anyalias,strings.Cut,bytes.Cut, generics - Go 1.19+: Type-safe atomics (
atomic.Bool,atomic.Pointer[T]) - Go 1.20+:
strings.Clone,strings.CutPrefix,errors.Join - Go 1.21+:
min/max/clearbuilt-ins,slicesandmapspackages,sync.OnceFunc - Go 1.22+:
for i := range nloops, fixed loop variables,cmp.Or, enhancedhttp.ServeMux - Go 1.23+: Iterator pattern with
maps.Keys,slices.Collect, safetime.Tick - Go 1.24+:
t.Context(),omitzeroJSON 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.modOnly 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.