Go Modernize
Go evolves. Code written for Go 1.16 should not look the same as code targeting
Go 1.25+. Modernize incrementally — update go.mod, then adopt new patterns.
Never adopt a feature above the go directive in go.mod. Raise the
directive deliberately, in its own commit, and only to a version the project's
CI and deployment images actually run.
Detailed reference material, loaded on demand:
references/generics.md— replacinginterface{}with type parameters, constraints, generic containers, when NOT to use generics.references/stdlib-migrations.md— before/after examples for slog, errors.Join, slices/maps helpers, range-over-int, and iterators.
Read a reference file only when the summary below is not enough.
Modernization Procedure
Check the
godirective ingo.mod— it caps which features you can use.Run the official modernizers first — they find and fix the mechanical migrations automatically:
# Go 1.26+ — the modernizers now live in go fix go fix ./... # Go 1.25 and earlier go run golang.org/x/tools/gopls/internal/analysis/modernize/cmd/modernize@latest -fix -test ./...Both rewrite source in place. Commit before running, and review the diff. If neither command is available, apply the table below manually.
Scan the table below for the judgment-based migrations the analyzer does not cover (generics, iterators, logger replacement) and apply them case by case.
Run
go build ./...and the test suite after each group of changes.
Feature Table by Go Version
| Go Version | Feature | Action |
|---|---|---|
| 1.13+ | errors.Is, errors.As |
Replace == error comparisons |
| 1.13+ | http.NewRequestWithContext |
Replace http.NewRequest |
| 1.16+ | embed |
Replace go-bindata / packr |
| 1.18+ | Generics | Replace interface{} utility functions |
| 1.20+ | errors.Join |
Replace manual error accumulation |
| 1.21+ | log/slog |
Replace log for structured logging |
| 1.21+ | slices, maps |
Replace hand-written slice/map utilities |
| 1.21+ | min, max builtins |
Replace math.Min/math.Max (float64-only) |
| 1.22+ | Range over int | Replace for i := 0; i < n; i++ |
| 1.23+ | Range over func | Replace callback-based iteration |
| 1.23+ | unique.Make |
Replace hand-rolled string interning |
| 1.24+ | for b.Loop() |
Replace for range b.N in benchmarks |
| 1.24+ | t.Context() |
Replace context.Background() in tests |
| 1.24+ | os.Root |
Replace manual path-traversal checks |
| 1.24+ | go.mod tool directive |
Replace the tools.go blank-import file |
| 1.24+ | runtime.AddCleanup |
Replace runtime.SetFinalizer |
| 1.25+ | testing/synctest |
Replace time.Sleep in concurrency tests |
| 1.25+ | sync.WaitGroup.Go |
Replace wg.Add(1) + go func(){defer wg.Done()} |
| 1.26+ | errors.AsType |
Replace errors.As with a declared target variable |
| 1.26+ | slog.NewMultiHandler |
Replace hand-written fan-out handlers |
| 1.26+ | new(expr) |
Replace a temp variable taken by address |
Key Migrations at a Glance
Generics — type-safe utilities (Go 1.18+)
// ❌ Before — loses type safety
func Contains(slice []interface{}, target interface{}) bool { /* ... */ }
// ✅ After — type-safe generic
func Contains[T comparable](slice []T, target T) bool { /* ... */ }
Use generics for container types (Set[T], Result[T]) and utility
functions. Do NOT use them where a single concrete type works, or as a
substitute for interfaces in runtime polymorphism.
Details and constraint patterns: references/generics.md.
Structured logging (Go 1.21+)
// ❌ Before
log.Printf("processing order %s for user %s", orderID, userID)
// ✅ After
slog.Info("processing order",
slog.String("order_id", orderID),
slog.String("user_id", userID),
)
Keep zap/zerolog only if you need their performance for high-throughput logging; for most services slog is sufficient.
errors.Join (Go 1.20+)
var errs []error
for _, item := range items {
if err := validate(item); err != nil {
errs = append(errs, err)
}
}
if err := errors.Join(errs...); err != nil {
return fmt.Errorf("validation: %w", err)
}
errors.Join preserves the chain — errors.Is/errors.As work on each
joined error. Never accumulate error strings manually.
slices and maps helpers (Go 1.21+)
found := slices.Contains(items, target) // not a manual loop
slices.SortFunc(users, func(a, b User) int { // not sort.Slice
return cmp.Compare(a.Name, b.Name)
})
keys := slices.Collect(maps.Keys(m)) // not a manual key loop
clone := maps.Clone(m) // not a manual copy loop
Range over int (Go 1.22+) and iterators (Go 1.23+)
for i := range n { process(i) } // not for i := 0; i < n; i++
for i, v := range slices.Backward(items) { // stdlib iterators
fmt.Printf("%d: %v\n", i, v)
}
Custom iter.Seq/iter.Seq2 iterators replace callback-based iteration —
full worked example in references/stdlib-migrations.md.
Context-aware HTTP requests (Go 1.13+, often missed)
// ❌ Before — request without context
req, err := http.NewRequest(http.MethodGet, url, nil)
// ✅ After — context propagated
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
Concurrency and tests (Go 1.24-1.25)
// ❌ Before
var wg sync.WaitGroup
for _, job := range jobs {
wg.Add(1)
go func() {
defer wg.Done()
process(job)
}()
}
wg.Wait()
// ✅ After — Go 1.25
var wg sync.WaitGroup
for _, job := range jobs {
wg.Go(func() { process(job) })
}
wg.Wait()
wg.Go cannot be called after wg.Wait returns, which removes the classic
"Add after Wait" race that the waitgroup vet analyzer (Go 1.25+) reports.
In tests, context.Background() becomes t.Context(), for range b.N
becomes for b.Loop(), and time.Sleep-based concurrency tests become
synctest.Test. → See the go-test-quality skill.
Error inspection (Go 1.26)
// ❌ Before — needs a declared target, and the pointer indirection is easy to get wrong
var pathErr *fs.PathError
if errors.As(err, &pathErr) {
log.Println(pathErr.Path)
}
// ✅ After — Go 1.26
if pathErr, ok := errors.AsType[*fs.PathError](err); ok {
log.Println(pathErr.Path)
}
errors.Is is unchanged. Only the As form gains a generic alternative.
Value interning and cleanup (Go 1.23-1.24)
// ✅ unique.Make deduplicates repeated values; Value() returns the canonical copy
h := unique.Make(hostname) // unique.Handle[string], comparable, cheap
store[h] = conn // one string kept in memory, not one per entry
// ✅ AddCleanup replaces SetFinalizer: multiple cleanups, no resurrection,
// and it works on objects that are part of a cycle
runtime.AddCleanup(obj, func(fd int) { syscall.Close(fd) }, obj.fd)
Reach for unique only where profiling shows duplicate values dominating the
heap — a config parser reading millions of rows, not a request handler.
Verification Checklist
go.modversion matches the features used in the codebase- No
interface{}whereanyor type parameters would be clearer log/slogused instead oflog.Printffor structured loggingerrors.Joinused instead of manual error string concatenationslices.Contains,slices.SortFunc,maps.Clonereplace hand-written loops- Range over int (
for i := range n) used where applicable http.NewRequestWithContextused instead ofhttp.NewRequest- No
sort.Slice— useslices.SortFuncwithcmp.Compare - Generics used for type-safe containers and utilities, not overused for trivial cases
- Third-party dependencies evaluated against stdlib alternatives added in recent Go versions
sync.WaitGroup.Goused instead of manualAdd/Donepairs (Go 1.25+)- Tests use
t.Context()andfor b.Loop()(Go 1.24+) tools.goreplaced by thego.modtool directive (Go 1.24+)