Go ships a new minor release every six months, and almost every release adds something useful to write modern code. This lesson walks through the major releases from Go 1.11 onwards, explains what each one added, and shows how to plan a migration when your codebase is several versions behind. The focus is practical: which features are worth adopting first, what to change in go.mod, and how to keep a library compatible with older Go versions while still using newer compiler features.
Each Go release is backwards compatible at the language level, which is why migrations are usually about adopting new features rather than fixing breakage. The table below lists the releases that matter most for everyday code. Older releases (1.0 through 1.10) are skipped because almost nothing still runs on them, and the modules system added in 1.11 is the practical baseline for any project today.
| Version | Released | Headline features |
|---|---|---|
| 1.11 | Aug 2018 | Go modules (go.mod, go.sum) preview |
| 1.13 | Sep 2019 | Wrapped errors, errors.Is, errors.As, %w verb |
| 1.16 | Feb 2021 | embed package, io/fs filesystem interface, modules on by default |
| 1.18 | Mar 2022 | Generics (type parameters), built-in fuzzing, any alias |
| 1.19 | Aug 2022 | Doc comment formatting, revised memory model |
| 1.20 | Feb 2023 | Profile-guided optimization (preview), errors.Join for multi-errors |
| 1.21 | Aug 2023 | slices, maps, cmp packages; min, max, clear builtins; toolchain directive |
| 1.22 | Feb 2024 | Enhanced net/http.ServeMux patterns; loop variable scoping fix |
| 1.23 | Aug 2024 | Range-over-func, iter package, unique package |
| 1.24 | Feb 2025 | Generic type aliases, weak pointers, improved map runtime |
The diagram below groups the releases by theme so you can see how features build on each other. Modules came first, then error handling matured, then generics opened the door to the generic standard library packages that came later.
The diagram doubles as a migration roadmap. If you're currently on 1.18 and want to move to 1.23, you cross five releases, and each one adds features your code can adopt. You don't have to adopt them all at once. A typical migration bumps the go directive, lets the code compile, and then sweeps in one feature at a time.
A quick tour of what each release actually changes for day-to-day code. The goal isn't to memorize the list; it's to know what to look for when you bump a version.
Go 1.11: Modules. Before modules, dependency management was a mess of GOPATH, vendoring tools, and unofficial package managers. Modules introduced go.mod and go.sum and made version selection deterministic. A go.mod file looks like this:
If you maintain code without a go.mod file, you're on an unsupported workflow. Run go mod init <module-path> to create one and let go mod tidy populate the requires.
Go 1.13: Wrapped errors. The fmt.Errorf function gained the %w verb, which wraps another error inside the new one. The errors.Is and errors.As functions then walk the chain.
Before 1.13, comparing wrapped errors required string matching or custom unwrap methods. After 1.13, errors.Is does it correctly across any number of wrapping layers.
Go 1.16: `embed` and `io/fs`. The //go:embed directive lets you bake files into the compiled binary. The io/fs package introduces the fs.FS interface so packages can accept any filesystem-like object.
If welcome.txt contains "Welcome to the store", that string is now embedded in the binary at compile time. No file I/O at runtime.
Go 1.18: Generics. Type parameters let you write functions and types that work for many element types without resorting to interface{} or code generation.
The same function works for both float64 and int slices because T is constrained to those two types.
Go 1.20: `errors.Join`. The errors.Join function combines multiple errors into one. Calling errors.Is or errors.As then walks all the joined errors, not just one chain.
Go 1.21: Generic helpers and builtins. The slices and maps packages provide common operations (Contains, Index, Sort, Equal, Clone, Keys, Values) without each project rolling its own. The cmp package adds Compare and Less for ordered types. The new builtins min and max work on any ordered type, and clear empties a slice or map in place.
Go 1.22: Enhanced `ServeMux` and the loopvar fix. The standard library's HTTP router learned method-specific patterns and path wildcards, so you don't need a third-party router for most apps. And the loop variable in for was changed so that closures and goroutines capture a fresh variable per iteration instead of sharing one, which fixes a category of bugs.
Go 1.23: Iterators. Range-over-func lets you write for v := range myIter where myIter is a function. The iter package defines the Seq and Seq2 types that pull-based iterators use, and packages like slices and maps added iterator-returning helpers (slices.All, maps.Keys, etc.).
This lesson's job is to tie features to specific releases so you know what becomes available when you bump versions.
go Directive and the Toolchain DirectiveThe go.mod file has two version-related directives, and they're easy to confuse. The go directive sets the language version: which language features and standard library APIs your code is allowed to use. The toolchain directive sets the toolchain version: which go command actually compiles the code. They were untangled in Go 1.21.
Reading this: the module's source code uses Go 1.22 language features, but a Go 1.23.4 toolchain is the minimum used to build it. If a user runs go build with Go 1.22.0 installed, the go command notices the toolchain mismatch and automatically downloads Go 1.23.4 to do the build. This behavior is controlled by the GOTOOLCHAIN environment variable, which defaults to auto.
| Directive | Controls | When to bump |
|---|---|---|
go 1.X | Language features and standard library APIs available | When you want to use a new feature (generics, slices, range-over-func) |
toolchain go1.X.Y | Minimum compiler used to build the module | When you need a specific compiler fix or want to force a newer build chain |
For a library you ship to others, the go directive matters more. It defines the lowest Go version your users need. The toolchain directive is optional, and setting it forces every user (and CI runner) to use at least that toolchain, which can be too aggressive for a widely-shared library.
Bumping the go directive in a public library cuts off users on older Go versions. They'll get a build error like module requires go >= 1.22. Pick the lowest version that gives you the features you actually need.
A common point of confusion is that the go directive doesn't strictly enforce features in older toolchains. Before Go 1.21, the directive was advisory: a Go 1.20 toolchain would compile a module that declared go 1.21, and you'd get cryptic errors only when you used a missing feature. From 1.21 onwards, the directive is checked, and a too-old toolchain refuses outright. That's part of why the toolchain directive exists, so newer toolchains can auto-upgrade themselves to satisfy the requirement.
Most migrations aren't about fixing breakage, they're about adopting newer features safely. The order matters: bump the go directive first, let the code keep working, then sweep through and modernize. Trying to refactor and bump versions in one commit makes diffs unreadable and bisection painful.
The diagram below shows a checkout service moving from Go 1.20 to Go 1.23 in four staged commits. Each stage is small enough to review and revert in isolation.
Each arrow is one or more pull requests, not one commit. The "adopt" stages are where you do the actual modernization, and they can stretch across several PRs as you touch each package.
If you're coming from before Go 1.18, generics are the headline change. The migration risk is low, generic code is opt-in, so adding it doesn't break anything that was using interface{} or code generation. The trick is knowing where generics are actually worth it.
Good candidates: helper functions that operate on slices of any type (filter, map, reduce), data structures parameterized by element type (queues, sets, LRU caches), and constraint-based numeric code. Bad candidates: business logic where the type is always the same, methods on a single concrete type, and anywhere the resulting signature gets harder to read than the original.
The generic Filter works for any element type, and the closure carries the predicate. Note that we didn't delete the old filterPositive. Mixing styles during a migration is fine. Convert callers gradually, then remove the old helpers in a cleanup PR.
slices and mapsAlmost every Go codebase older than 1.21 has its own Contains, IndexOf, or MapKeys helpers, often spread across multiple packages. The slices and maps packages replace those, and the replacements are well-tested, generic, and (for slices.Sort) faster than the older sort.Slice reflection-based version.
A grep-based audit usually finds the candidates fast. Search for function names like Contains, IndexOf, Find, Keys, Values, Equal, Reverse. Each match is a potential one-line replacement.
slices.Sorted and slices.Values are Go 1.23 additions for working with iterators, but slices.Contains, slices.Index, slices.Compact, and slices.Clone are all there from 1.21. The min, max, and clear builtins are part of the same release: min(price1, price2) returns the smaller, and clear(cart) empties a slice in place.
Two big changes ship in 1.22, and both can affect existing code in subtle ways.
The loop variable change is mostly a silent improvement, but it's worth doing a manual audit of any for loop that starts a goroutine or captures the loop variable in a closure. Pre-1.22, the classic bug looked like this:
On Go 1.21 and earlier, the same code might print "103" three times because all three goroutines captured the same id variable. After 1.22, each iteration captures a fresh id. The fix used to be id := id inside the loop body, and those redundant shadows are now dead code you can clean up. Run go vet with the -loopclosure analyzer to find any remaining cases.
The other change is net/http.ServeMux. The built-in router now supports method patterns and path wildcards, so a lot of code that imported gorilla/mux or chi can simplify back to the standard library.
Method-specific routes (GET, POST) and {id} wildcards remove the most common reasons to use a third-party router. If the project is using gorilla/mux for these features only, swapping back is a straightforward refactor.
gorilla/mux and other routers offer regex constraints, sub-routers, and middleware patterns that ServeMux still doesn't. Don't ditch a third-party router during a migration if your code depends on features the standard library doesn't have.
Range-over-func is the visible new feature in 1.23, but you don't have to convert every iteration pattern. Start with new code and with places where a generator or pull-based iterator simplifies the code.
A common candidate is filtering a large input where you don't want to materialize an intermediate slice. The pre-1.23 version typically returned []T, which forced a full pass and an allocation. The 1.23 version returns an iter.Seq[T] and produces values lazily.
The advantage isn't speed in this small example, it's composability. An iter.Seq[string] plugs into anything that takes a range-able sequence, and you can stop iterating early by break-ing out of the loop.
The Go toolchain comes with a handful of utilities that catch version-specific problems and apply mechanical fixes. The two most useful are go vet and gopls, but a few third-party linters also pay for themselves on any non-trivial migration.
`go vet` runs a fixed set of analyzers built into the Go distribution. It catches common mistakes: shadowed variables, unreachable code, printf formatting bugs, missing keys in struct literals, and so on. Each release adds new analyzers. Go 1.22's vet, for example, warns about the old for loop variable capture pattern in code targeting earlier versions. Run go vet./... at the start of any migration to see what the current toolchain finds.
`gopls` is the language server most editors use for Go. It runs many of the same analyzers as go vet plus its own quick fixes and refactorings. When you bump the go directive in go.mod, gopls re-analyzes your code against the new version and starts suggesting modernizations (replacing a hand-written Contains with slices.Contains, for example). Make sure gopls is up to date with go install golang.org/x/tools/gopls@latest.
`go fix` is a less commonly used tool that applies known automatic rewrites for deprecated APIs. It's most useful when an older release deprecated a function and a newer one removed it; go fix will swap call sites to the replacement. The tool's scope is narrow, but it's saved time when an internal package's API was renamed.
`golangci-lint` wraps dozens of community linters (staticcheck, gosimple, unused, govet, and many more) behind a single command. A common pattern during migrations is to enable gocritic and staticcheck and let them flag patterns that newer Go versions can simplify, like checking for an empty slice with len(s) == 0 when slices would do the job, or building a sorted unique list manually when slices.Sorted(slices.Compact(...)) is shorter.
Running every linter on a large codebase produces hundreds of warnings on the first run. Enable linters in batches, fix the new ones, and treat them as gates for new code rather than a backlog to clear in one sitting.
A good migration checklist is: bump the go directive, run go mod tidy, run go vet./..., run gopls check./... (or open the project in your editor and look at the diagnostics), then run golangci-lint run if you use it. Fix or suppress each warning. Only then start hand-modernizing code.
If you maintain a library, you typically can't bump the go directive aggressively, your users may still be on older Go versions. The strategy is to declare the lowest Go version that gives you the features your library actually requires, and then use build tags or runtime checks for newer features that are nice but not essential.
The simplest case: don't use new features at all. Declare go 1.21 (or whatever the oldest supported version is) and stick to its standard library. This is the safest option for libraries with a broad user base. Major libraries like gin and chi move slowly on the go directive for exactly this reason.
The next step up is build tags. The //go:build directive can target specific Go versions. A common pattern is to put an iterator-returning API in a file gated by go1.23 and a fallback (slice-returning) API in a file gated by !go1.23.
On Go 1.23 or later, the iterator version compiles. On older versions, only the slice version compiles. Callers can use whichever exists for their toolchain.
For most libraries, the cleanest answer is to pick a single supported version and revisit it every six months. Set go 1.21 for now, and bump to go 1.22 once your users have had a year to upgrade. The Go team's own policy is to support the two most recent major releases, so any library that supports the same window stays in the maintained zone.
| Strategy | Pros | Cons |
|---|---|---|
Single low go directive | Simple; works for all users | Can't use new features |
| Build tags per file | Different code per version | More files; tested matrix grows |
| Drop old versions outright | Modern code; less complexity | Cuts off some users |
| Minor-version constraints | Fine-grained | Easy to get wrong; rare in practice |
The other thing to remember is that breaking changes in the standard library are rare. The Go 1 compatibility promise means standard library APIs added in 1.0 still work in current Go. The risk is mostly that newer APIs (slices.Contains, errors.Join, iter.Seq) won't exist in older toolchains, not that old code will break under a newer one.
10 quizzes