# Go Version Feature Map

> The consolidated Go 1.21→1.26 feature reference and modernization rule — which idiom is available given the module's `go` directive, and prefer the modern builtin/stdlib form over the stale hand-rolled one. The `go` line gates which language features compile (range-over-func needs `go 1.23`, per-iteration loop variables need `go 1.22`), so "idiomatic" means *current* idiomatic for the declared version. Catches the pre-modern Go that older training data emits: the `tc := tc` loop copy (unneeded since 1.22), `for i := 0; i less than b.N; i++` benchmarks (use `b.Loop`, 1.24), hand-rolled `min`/`max`/`Contains` (builtins/`slices`, 1.21), `omitempty` on `time.Time` (use `omitzero`, 1.24), the `errors.As` out-param dance (use `errors.AsType[T]`, 1.26), and the `tools.go` hack (use `tool` directives, 1.24). Auto-invokes when choosing a Go idiom that depends on version, setting or raising the `go` directive, modernizing old Go, or on "what version is this from" / "is there a newer way" questions.

- Skill: `ctoth/go-version-feature-map` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add ctoth/go-version-feature-map`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ctoth/go-version-feature-map/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: ctoth (https://skillmd.com/u/ctoth)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ctoth/go-version-feature-map

---


# Go Version Feature Map

> "The `go` line for each module sets the language version the compiler enforces when compiling packages in that module."
> — [Go Toolchains](https://go.dev/doc/toolchain)

> "the compiler rejects use of language features introduced after the version specified by the `go` directive"
> — [Go Modules Reference](https://go.dev/ref/mod)

Go evolves fast, and most LLM training data is older than the toolchain in front of you. The recurring failure is **pre-modern Go**: code that compiles but is written for a Go that predates the builtins, loop semantics, and stdlib helpers the current release ships. This skill is the consolidated **version reference** (1.21→1.26) and one rule: *check the module's `go` directive, then use the modern form it unlocks.* "Idiomatic" is not timeless — it means current-idiomatic for the version you declared.

This skill is the table; the feature-owning skills (`go-error-handling`, `go-slices-and-maps`, `go-json`, …) own the depth. The `go` directive itself — how to set and raise it — is owned by `go-modules-and-versioning`.

---

## 1. The `go` Directive Gates Which Features Compile

The `go` line in `go.mod` is not cosmetic metadata. It "sets the language version the compiler enforces" ([Go Toolchains](https://go.dev/doc/toolchain)), and "the compiler rejects use of language features introduced after the version specified by the `go` directive" ([Modules Reference](https://go.dev/ref/mod)). So:

- `go 1.21` → `min`/`max`/`clear` builtins available; **range-over-func and per-iteration loop variables are not.**
- `go 1.22` → per-iteration loop variables and `for i := range n` turn on.
- `go 1.23` → range-over-func iterators compile.

Two consequences that this skill exists to keep straight:

1. **Down-gating breaks modern code.** Writing `for range seq` (a range-over-func) in a module that declares `go 1.21` is a compile error, not a runtime surprise. If you need a feature, the `go` line must be at least its version.
2. **Some features are gated by the directive even when the *toolchain* is newer.** The 1.22 loop-variable change and the 1.23 timer-channel change are tied to the module's `go` line, not the installed compiler — Go 1.26 still compiles a `go 1.21` module with the *old* loop semantics ([go1.23 — Timer changes](https://go.dev/doc/go1.23): "only enabled when the main Go program is in a module with a `go.mod` `go` line using Go 1.23.0 or later"). Per-file build constraints can raise the language version for one file ([Go Toolchains](https://go.dev/doc/toolchain)).

**Raising the `go` line is a real decision** — it unlocks idioms *and* locks out consumers on older Go. Before bumping it, see `go-modules-and-versioning` §4 for the directive/`toolchain` mechanics; this skill tells you *what each line unlocks*.

**The operational check** (with this skill's `Read`/`Grep` tools): before choosing or modernizing an idiom, find the gate first.

```bash
# 1. Read the module's declared version — the gate for every language feature below.
grep -E '^go [0-9]' go.mod        # -> e.g. "go 1.24.0"
```

Then consult §3 / `references/version-table.md`: if the idiom's minimum version is `<=` the `go` line, use the modern form; if it is higher, either use the older form or (deliberately) raise the line. Never emit a feature newer than the declared version — it will not compile.

---

## 2. The Modernization Rule

> Prefer the modern builtin, the modern stdlib helper, and the modern language form — up to the limit the `go` directive allows. Recognize the stale pre-modern pattern and replace it.

When you author or review Go, run the value/loop/error/test construct against the version map:

- A hand-rolled `func max(a, b int) int` is **stale** on `go 1.21+` — `max` is a builtin.
- A `tc := tc` copy inside a `for _, tc := range cases` is **stale** on `go 1.22+` — the loop variable is already per-iteration.
- A `for i := 0; i < b.N; i++` benchmark is **stale** on `go 1.24+` — use `for b.Loop()`.
- `omitempty` on a `time.Time` field is **a bug** on `go 1.24+` — it never omits a zero time; use `omitzero`.
- The `errors.As(err, &target)` out-parameter dance is **stale** on `go 1.26+` — use `errors.AsType[T](err)`.

The full mapping — feature, the OLD pattern it replaces, the idiomatic NEW form, and a primary-source URL — is in [references/version-table.md](references/version-table.md). The high-frequency stale-pattern fixes are in [references/common-mistakes.md](references/common-mistakes.md).

---

## 3. The Version Map at a Glance

Each row: the headline additions and the stale pattern they retire. `→` reads "replaces." Full detail and per-feature URLs in `references/version-table.md`.

| `go` line | Headline language/builtin additions | Headline stdlib additions | Retires (stale → modern) |
|---|---|---|---|
| **1.21** | `min`, `max`, `clear` builtins | `slices`, `maps`, `cmp`; `log/slog`; `sync.OnceFunc`/`OnceValue`/`OnceValues`; `context.WithoutCancel`/`AfterFunc`/`WithDeadlineCause` | hand-rolled `max`/`min`/`Contains`/`Sort` → builtins + `slices`; `log`/`fmt.Println` → `slog` |
| **1.22** | per-iteration **loop variables**; `for i := range n` (range over int) | `math/rand/v2`; routing patterns in `net/http.ServeMux` | `x := x` capture copy → (nothing); `for i := 0; i < n; i++` → `for i := range n` |
| **1.23** | range-over-func iterators (`for range seq`) | `iter`; `slices`/`maps` iterator funcs (`slices.Sorted`, `maps.Keys`, …); `unique`; GC-able/synchronous timer channels | manual key-collection loops → `slices.Sorted(maps.Keys(m))`; leaked unstopped timers |
| **1.24** | generic type aliases | `omitzero` JSON tag; `tool` directives; `testing.B.Loop`; `weak`; `runtime.AddCleanup`; `os.Root`; Swiss-table maps | `omitempty` on `time.Time` → `omitzero`; `tools.go` hack → `tool`; `for i := 0; i < b.N` → `b.Loop()`; `runtime.SetFinalizer` → `AddCleanup` |
| **1.25** | (no language changes) | `testing/synctest` (stable); `sync.WaitGroup.Go`; `encoding/json/v2` (experiment); container-aware `GOMAXPROCS`; `go vet` `waitgroup`/`hostport`; trace flight recorder; Green Tea GC (experiment) | manual `wg.Add(1)`/`go`/`defer wg.Done()` → `wg.Go(...)`; flaky time-based concurrency tests → `synctest` |
| **1.26** | `new(expr)` (initializer); self-referential generic constraints | `errors.AsType[T]`; `slog.NewMultiHandler`; Green Tea GC (default); `go fix` modernizers + `//go:fix` | `errors.As(err, &t)` out-param → `errors.AsType[T](err)`; `p := &x` boxing dance → `new(expr)` |

---

## 4. Per-Version Highlights (stale → modern)

The features most likely to catch an LLM emitting old Go. Each shows the pattern it retires.

**1.21 — `min`/`max`/`clear` are builtins.** "The new functions `min` and `max` compute the smallest (or largest, for `max`) value of a fixed number of given arguments"; "The new function `clear` deletes all elements from a map or zeroes all elements of a slice" ([go1.21](https://go.dev/doc/go1.21)). Stop writing a generic `Max[T]` helper or a `for k := range m { delete(m, k) }` loop. The `slices`/`maps`/`cmp` packages and `log/slog` also promote to the stdlib here.

```go
func maxInt(a, b int) int { if a > b { return a }; return b } // STALE
hi := max(a, b)                                               // MODERN (1.21) — builtin, variadic
```

**1.22 — the loop variable is per-iteration.** "Previously, the variables declared by a 'for' loop were created once and updated by each iteration. In Go 1.22, each iteration of the loop creates new variables, to avoid accidental sharing bugs" ([go1.22](https://go.dev/doc/go1.22)). The classic `x := x` copy before a goroutine or `t.Parallel()` is **dead weight** on `go 1.22+`. This is gated on the `go` line: a `go 1.21` module still has the old, buggy semantics. Also: "'For' loops may now range over integers" — `for i := range 10` ([go1.22](https://go.dev/doc/go1.22)).

```go
for _, v := range xs { v := v; go use(v) } // STALE — the copy is now redundant
for _, v := range xs { go use(v) }         // MODERN (1.22) — v is fresh each iteration
for i := 0; i < n; i++ { ... }             // STALE      →  for i := range n { ... } // MODERN
```

**1.23 — range-over-func iterators.** The for-range clause "now accepts iterator functions" of type `func(func() bool)`, `func(func(K) bool)`, and `func(func(K, V) bool)` ([go1.23](https://go.dev/doc/go1.23)). The `iter` package and iterator-returning helpers (`slices.Sorted`, `slices.All`, `maps.Keys`, `maps.Values`) land here. Owned by `go-iterators-rangefunc`.

```go
keys := make([]string, 0, len(m))                  // STALE — collect, sort, range
for k := range m { keys = append(keys, k) }
sort.Strings(keys)
for _, k := range keys { use(k) }
for _, k := range slices.Sorted(maps.Keys(m)) { use(k) } // MODERN (1.23) — one expression
```

**1.24 — `omitzero`, `tool`, `b.Loop`.** "a struct field with the new `omitzero` option ... will be omitted if its value is zero ... unlike `omitempty`, `omitzero` omits zero-valued `time.Time` values, which is a common source of friction" ([go1.24](https://go.dev/doc/go1.24)). Benchmarks "may now use the faster and less error-prone `testing.B.Loop` method ... in place of the typical loop structures involving `b.N`" ([go1.24](https://go.dev/doc/go1.24)). And `tool` directives "remove the need for the previous workaround of adding tools as blank imports to a file conventionally named 'tools.go'" ([go1.24](https://go.dev/doc/go1.24)).

```go
At time.Time `json:"at,omitempty"` // STALE/BUGGY — never omits a zero time
At time.Time `json:"at,omitzero"`  // MODERN (1.24) — drops the zero time
for i := 0; i < b.N; i++ { ... }   // STALE      →  for b.Loop() { ... } // MODERN (1.24)
```

**1.25 — `WaitGroup.Go`, `synctest`.** "The new `WaitGroup.Go` method makes the common pattern of creating and counting goroutines more convenient" ([go1.25](https://go.dev/doc/go1.25)) — it does the `Add`/`Done` for you. `testing/synctest` "graduated to general availability" for deterministic, virtual-time concurrency tests ([go1.25](https://go.dev/doc/go1.25)). `encoding/json/v2`, container-aware `GOMAXPROCS`, and Green Tea GC arrive as opt-ins this release.

```go
wg.Add(1); go func() { defer wg.Done(); work() }() // STALE
wg.Go(func() { work() })                            // MODERN (1.25)
```

**1.26 — `errors.AsType[T]`, `new(expr)`.** "The new `AsType` function is a generic version of `As`. It is type-safe, faster, and, in most cases, easier to use" ([go1.26](https://go.dev/doc/go1.26)). "The built-in `new` function ... now allows its operand to be an expression, specifying the initial value" ([go1.26](https://go.dev/doc/go1.26)). Self-referential generic constraints (`type Adder[A Adder[A]] interface{...}`) also become legal.

```go
var nf *NotFoundError; if errors.As(err, &nf) { ... }     // STALE — out-param dance
if nf, ok := errors.AsType[*NotFoundError](err); ok { ... } // MODERN (1.26)
age := years(t); p := &age                                 // STALE  →  new(years(t)) // MODERN (1.26)
```

---

## 5. The Modernization Tool: `go fix` (1.26)

You do not have to apply these rewrites by hand. As of Go 1.26, "The venerable `go fix` command has been completely revamped and is now the home of Go's *modernizers*. It provides a dependable, push-button way to update Go code bases to the latest idioms and core library APIs" — including "a source-level inliner that allows users to automate their own API migrations using `//go:fix inline` directives" ([go1.26](https://go.dev/doc/go1.26)). The same fixers ship in the `modernize` analyzer (`golang.org/x/tools/.../modernize`, runnable via `gopls`). Run them and the loop-copy, hand-rolled `min`, and `b.N` patterns get rewritten for you. The tool/CI wiring is owned by `go-tooling-and-static-analysis`; this skill is the human-readable table of *what* they modernize to.

---

## 6. The Failure This Skill Targets

The recurring LLM failure is **emitting pre-modern Go because the training data is older than the toolchain.** The tells, all corrected by the version map:

- `tc := tc` inside a range loop (the copy is unnecessary on `go 1.22+`).
- `for i := 0; i < b.N; i++` benchmarks (use `for b.Loop()` on `go 1.24+`).
- A hand-rolled `func max(a, b int) int` or `func contains(...)` (`max` is a builtin since 1.21; `slices.Contains` exists).
- `interface{}` where `any` reads (the `any` alias has existed since 1.18).
- `omitempty` on a `time.Time` (it never drops a zero time; use `omitzero` on 1.24+).
- `errors.As(err, &target)` when `errors.AsType[T](err)` is available (1.26).
- A `tools.go` blank-import file on a 1.24+ module (use `tool` directives).

The opposite failure also exists: **using a feature newer than the `go` directive allows.** Writing a range-over-func in a `go 1.21` module is a compile error. The rule covers both directions — match the idiom to the declared version.

---

## 7. Routing to the Feature-Owning Skills

This skill is the **map**; each feature's depth lives in its owner.

- `go-idiomatic-discipline` — the policy root: "idiomatic" means *current* idiomatic, which is exactly what this map operationalizes.
- `go-modules-and-versioning` — **tight link**: owns the `go` directive and `toolchain` line mechanics (how to set/raise §1's gate); this skill owns *what each line unlocks*.
- `go-tooling-and-static-analysis` — owns `go fix` / the `modernize` analyzer that auto-applies §4's rewrites (§5).
- `go-concurrency-goroutines` — loop variable (1.22) in goroutines, `WaitGroup.Go` (1.25).
- `go-testing-tabledriven` / `go-testing-advanced` — `tc := tc` removal (1.22), `b.Loop` (1.24), `synctest` (1.25).
- `go-json` — `omitzero` (1.24), `encoding/json/v2` (1.25 experiment).
- `go-error-handling` — `errors.AsType[T]` (1.26), `errors.Join` (1.20).
- `go-slices-and-maps` — `min`/`max`/`clear` (1.21), the `slices`/`maps` packages and their iterator funcs (1.21/1.23).
- `go-iterators-rangefunc` — range-over-func and `iter` (1.23).
- `go-generics` — generic type aliases (1.24), self-referential constraints (1.26).

---

## 8. Reference Files

The full consolidated 1.21→1.26 feature → replacement → version table, with a per-feature primary-source URL:

[references/version-table.md](references/version-table.md)

High-frequency stale-Go anti-patterns, each STALE vs MODERN with the version gate and a release-note citation:

[references/common-mistakes.md](references/common-mistakes.md)

Source provenance for every claim in this skill:

[references/sources.yaml](references/sources.yaml)

