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

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 | // Before (Go 1.17 and earlier) |
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 | // Before (Go 1.12 and earlier) — breaks with wrapped errors |
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 | // Before — losing information |
Go 1.26 — Type-asserting errors without a temporary variable
1 | // Before |
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 | // Before — verbose, easy to get off-by-one |
The same API is available on bytes.Cut for byte slices.
Go 1.20 — Trimming known prefixes and suffixes
1 | // Before |
Go 1.24 — Iterating over split results without allocating a slice
1 | // Before — allocates []string just to iterate |
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 | // Before |
Go 1.22 — Range over integers
1 | // Before |
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 | // Before Go 1.22 — all goroutines capture the same variable |
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 | // Before — manual loops everywhere |
Map utilities
1 | // Before |
Go 1.22 — cmp.Or for first-non-zero fallback chains
1 | // Before — the classic "or empty string" chain |
Go 1.23 — maps.Keys and maps.Values return iterators
1 | // Before — materializes a full slice just to sort keys |
7. Concurrency Primitives
Go 1.19 — Type-safe atomics
1 | // Before — untyped, easy to mix up Store/Load/Add on wrong types |
Go 1.20 — Cancellation with a cause
1 | // Before — you know the context was cancelled, but not why |
Go 1.21 — sync.OnceFunc and sync.OnceValue
1 | // Before — boilerplate every time you want single-initialization |
Go 1.25 — wg.Go replaces the Add/Done dance
1 | // Before — three lines of ceremony per goroutine |
8. Testing and Benchmarking
Go 1.24 — t.Context() in tests
1 | // Before — manual context management in every test that needs one |
Go 1.24 — b.Loop() in benchmarks
1 | // Before — b.N loop, easy to accidentally include setup in the measured path |
Go 1.24 — omitzero in JSON struct tags
1 | // Before — omitempty doesn't work correctly for time.Duration, time.Time, or structs |
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 | // Before — a temporary variable just to get a pointer |
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!
Comments