go-idioms — modern Go (modernize)
Advice == tooling. The modernize analyzers flag and usually auto-fix most of what follows — the
Fixer column says which, and the last section covers what no fixer automates. Run the tool,
don't hand-audit. As of Go 1.26 the rewritten go fix is the canonical runner — it ships the
modernizer suite in the toolchain itself:
go fix -diff ./... # preview the rewrite as a unified diff (clean tree first)
go fix ./... # apply; -<fixer> runs one, -<fixer>=false excludes one
golangci-lint run --enable-only=modernize --fix # the x/tools modernize suite — includes the † fixers below
Both draw on the same golang.org/x/tools engine as gopls, but golangci-lint pins its own (usually
newer) snapshot of it — that gap is what the † marker below tracks. This skill explains why
and catches what review notices before the tool runs. The baseline is Go 1.26.4+ (Go 1.27 is
supported too; its additions are flagged as hints in Newer in Go 1.27 below), so every row
below applies as written — the Since column is provenance: it explains why older code looks
different, and what an older module would have to bump to before adopting the idiom.
Prefer → over (since)
The Fixer column names the analyzer that owns each rewrite. Plain = registered in the Go 1.26.4
toolchain's go fix (ground truth: go tool fix help; per-fixer docs: go tool fix help <name>).
† = only in the newer x/tools suite so far — golangci-lint's modernize and gopls run it, the
1.26.4 toolchain's go fix does not. — = no fixer exists: review has to catch it. Two † rows
below (atomictypes, slicesbackward) graduate into the stock go fix on 1.27, which also renames
waitgroup → waitgroupgo and drops fmtappendf (https://go.dev/doc/go1.27) — see
Newer in Go 1.27 below rather than reading that as a change to this table.
| Prefer |
Over |
Since |
Fixer |
new(expr) — e.g. Field: new(30), new(int64(req.Limit)) |
a ptr[T](v) helper or a hand-written tmp := v; &tmp, for optional/pointer fields |
1.26 |
newexpr |
errors.AsType[E](err) |
errors.As(err, &target) → go-errors |
1.26 |
errorsastype † |
for i := range n |
for i := 0; i < n; i++ |
1.22 |
rangeint |
min(a, b) / max(a, b) builtins |
hand-rolled helpers |
1.21 |
minmax |
(drop) x := x loop-var copy |
pre-1.22 capture workaround |
1.22 |
forvar |
any |
interface{} |
1.18 |
any |
slices.Sort/Contains, slices.Collect, maps.Keys |
hand-rolled sort/contains/map loops |
1.21–1.23 |
slicescontains, slicessort, mapsloop |
for i, v := range slices.Backward(s) |
for i := len(s)-1; i >= 0; i-- |
1.23 |
slicesbackward † |
strings.Cut / CutPrefix / CutSuffix |
Index + manual slicing |
1.18/1.20 |
stringscut, stringscutprefix |
strings.SplitSeq / FieldsSeq |
ranging over strings.Split/Fields (allocates a slice) |
1.24 |
stringsseq |
fmt.Appendf(b, …) |
append(b, fmt.Sprintf(…)...) / []byte(fmt.Sprintf(…)) |
1.19 |
fmtappendf |
omitzero on a struct-typed json field |
omitempty, which does nothing for struct fields — a zero time.Time still marshals |
1.24 |
omitzero |
t.Context() in tests |
context.WithCancel(context.Background()) → go-testing |
1.24 |
testingcontext |
reflect.TypeFor[T]() |
reflect.TypeOf((*T)(nil)).Elem() |
1.22 |
reflecttypefor |
cmp.Or(a, b, …) |
nested if x == "" { x = y } |
1.22 |
— |
sync.OnceFunc / OnceValue |
sync.Once + a captured var |
1.21 |
— |
iter.Seq[V] / range-over-func |
Visit(callback) patterns, exposing slices |
1.23 |
stditerators |
slog.LogAttrs(ctx, lvl, msg, attrs…) on hot paths |
key-value variadic slog (allocates) |
1.21 |
— |
errors.Join |
manual multi-error concat → go-errors |
1.20 |
— |
wg.Go(...) |
wg.Add(1)/defer wg.Done() → go-concurrency |
1.25 |
waitgroup |
for b.Loop() |
for i := 0; i < b.N; i++ → go-testing |
1.24 |
bloop † |
typed atomic.Int64 |
bare-int atomic.Add* → go-concurrency |
1.19 |
atomictypes † |
Idioms are a moving target — let the tool (pinned to the repo's toolchain) be the source of
truth so advice never drifts from the user's go fix. Go 1.26 also lifts the ban on a generic type
referencing itself in its own type-parameter list (e.g. type Adder[A Adder[A]] interface{ Add(A) A }),
so self-referential constraints no longer need a workaround — but that's a hand-written pattern, not
something a modernizer rewrites.
Modern, but no fixer automates it
os.OpenRoot(dir) → *os.Root (1.24) for anything that opens a caller-supplied path: its
methods cannot escape the directory, including via symlink. Replaces filepath.Join plus
hand-written ../prefix checks — the traversal-bug pattern those checks keep getting wrong.
rand.Text() from crypto/rand (1.24) for tokens, nonces, and IDs — not math/rand, and not
a hand-rolled base64 of rand.Read. Security-sensitive randomness always comes from crypto/rand.
var s []T, not s := []T{} — the nil slice is the idiomatic empty slice (append works,
len is 0). Reach for the non-nil literal only when something genuinely distinguishes them
(e.g. marshalling [] vs null).
slices.Sorted(maps.Keys(m)) (1.23) when iterating a map for output — map order is random, and
unstable output is a flaky-test and noisy-diff source.
- A nested
:= can shadow err or ctx. if v, err := f(); err != nil { … } inside a function
that already has an err declares a second one; the outer stays nil and a later return err
reports success. Use = to assign into the existing variable, or give the inner one another name.
Nothing in the standard set reports this; the shadow analyzer from golang.org/x/tools does
when enabled (in golangci-lint: linters.settings.govet.enable: [shadow]) — it is noisy on
legitimate reuse, so a repo enables it deliberately rather than by default.
- No
break at the end of a switch case. Go cases do not fall through, so a break as the
last statement of a case is dead text; staticcheck S1023 (standard set) flags exactly that.
Earlier in a case a break still does work — it leaves the switch from that point — and a
labelled break leaves the enclosing loop instead; neither of those is dead text.
Go 1.27 (released 2026-08-19, https://go.dev/dl/) graduates several † fixers into the toolchain's
go fix (atomictypes, slicesbackward, plus new embedlit and unsafefuncs), renames waitgroup
→ waitgroupgo, and drops fmtappendf; it also lands encoding/json/v2 + encoding/json/jsontext
(v1 is reimplemented on v2, opt out with GOEXPERIMENT=nojsonv2) and strings.CutLast/bytes.CutLast.
Source: https://go.dev/doc/go1.27. See Newer in Go 1.27 below for the hints these enable — none
of it is required on the 1.26 floor.
Newer in Go 1.27 (hints, not requirements)
Go 1.27 is additive over 1.26 — every 1.26 rule above still applies unchanged, and nothing here is
required while a module's go directive stays at 1.26. Once a repo's toolchain (and go directive)
moves to 1.27, these are worth reaching for.
| Idiom (available from 1.27) |
Supersedes / complements |
Fixer / linter |
Since |
Source |
| Generic methods — a method may declare its own type parameters (interface methods still may not declare type parameters, nor be implemented by generic methods) |
a package-level generic helper function bound to the receiver type as a workaround for "methods can't be generic" |
— |
1.27 |
go.dev/doc/go1.27 |
encoding/json/v2 + jsontext when you want v2's stricter semantics (invalid UTF-8 and duplicate object names rejected) or its Options |
v1 encoding/json — keep it: it now runs on the v2 implementation underneath, keeps its behaviour (only exact error text may shift), gets the faster unmarshal for free, and stays supported. The release notes are explicit: "users are not required to migrate". Opt out of the new backend with GOEXPERIMENT=nojsonv2 |
— |
1.27 |
go.dev/doc/go1.27 |
atomictypes — graduates into the stock go fix (previously † golangci-lint-only, row above) |
raw sync/atomic functions |
atomictypes |
1.27 |
go.dev/doc/go1.27 |
slicesbackward — graduates into the stock go fix (previously † golangci-lint-only, row above) |
for i := len(s)-1; i >= 0; i-- |
slicesbackward |
1.27 |
go.dev/doc/go1.27 |
embedlit — initialise a field promoted from an embedded struct directly in the parent literal: T{U: U{x: 1}} → T{x: 1} |
the nested literal an embedded struct used to require |
embedlit |
1.27 |
modernize |
unsafefuncs — unsafe.Pointer(uintptr(ptr) + uintptr(n)) → unsafe.Add(ptr, n) |
hand-rolled unsafe pointer arithmetic (unsafe.Add itself is 1.17; the fixer is new) |
unsafefuncs |
1.27 |
modernize |
go test runs the stdversion vet check by default from 1.27. The check itself is not new —
what changes is that it becomes automatic. It flags stdlib symbols newer than the go directive in
force for the file, which is the guardrail that keeps a 1.26-floor module from silently depending on
a 1.27-only symbol. Trust it over manual review for this. Source:
go.dev/doc/go1.27.
Sources
Decomposition inspired by samber/cc-skills-golang (MIT © 2026 Samuel Berthe); rules grounded in the sources above.
1---2name: go-idioms3description: Modern idiomatic Go (`modernize`) — Go 1.26+, 1.27 additions noted. This skill should be used when a diff contains a rewritable construct, when the user asks to modernize Go or run `go fix`, or asks which fixer owns a rewrite — range-over-int, `min`/`max`, `slices`/`maps`, `strings.Cut`, `any`, iterators, `omitzero`, `os.Root`, `new(expr)`, `errors.AsType`, and Go 1.27's `atomictypes`, `embedlit`, `slicesbackward`, `unsafefuncs` — rewrites go to `go fix ./...` or `golangci-lint --enable-only=modernize`, a nested `:=` that shadows `err` to the opt-in `shadow` analyzer, a redundant `break` in a `switch` to staticcheck S1023. Not for linter configuration (go-lint-setup). Go only.4---56# go-idioms — modern Go (modernize)78**Advice == tooling.** The `modernize` analyzers flag and usually auto-fix most of what follows — the9**Fixer** column says which, and the last section covers what no fixer automates. Run the tool,10don't hand-audit. As of **Go 1.26** the rewritten `go fix` is the canonical runner — it ships the11modernizer suite in the toolchain itself:1213```14go fix -diff ./... # preview the rewrite as a unified diff (clean tree first)15go fix ./... # apply; -<fixer> runs one, -<fixer>=false excludes one16golangci-lint run --enable-only=modernize --fix # the x/tools modernize suite — includes the † fixers below17```1819Both draw on the same `golang.org/x/tools` engine as gopls, but golangci-lint pins its own (usually20newer) snapshot of it — that gap is what the **†** marker below tracks. This skill explains *why*21and catches what review notices before the tool runs. The **baseline is Go 1.26.4+** (Go 1.27 is22supported too; its additions are flagged as hints in **Newer in Go 1.27** below), so every row23below applies as written — the `Since` column is provenance: it explains why older code looks24different, and what an older module would have to bump to before adopting the idiom.2526## Prefer → over (since)2728The **Fixer** column names the analyzer that owns each rewrite. Plain = registered in the Go 1.26.429toolchain's `go fix` (ground truth: `go tool fix help`; per-fixer docs: `go tool fix help <name>`).30**†** = only in the newer `x/tools` suite so far — golangci-lint's `modernize` and gopls run it, the311.26.4 toolchain's `go fix` does not. `—` = no fixer exists: review has to catch it. Two † rows32below (`atomictypes`, `slicesbackward`) graduate into the stock `go fix` on 1.27, which also renames33`waitgroup` → `waitgroupgo` and drops `fmtappendf` (<https://go.dev/doc/go1.27>) — see34**Newer in Go 1.27** below rather than reading that as a change to this table.3536| Prefer | Over | Since | Fixer |37|---|---|---|---|38| `new(expr)` — e.g. `Field: new(30)`, `new(int64(req.Limit))` | a `ptr[T](v)` helper or a hand-written `tmp := v; &tmp`, for optional/pointer fields | 1.26 | `newexpr` |39| `errors.AsType[E](err)` | `errors.As(err, &target)` → `go-errors` | 1.26 | `errorsastype` † |40| `for i := range n` | `for i := 0; i < n; i++` | 1.22 | `rangeint` |41| `min(a, b)` / `max(a, b)` builtins | hand-rolled helpers | 1.21 | `minmax` |42| *(drop)* `x := x` loop-var copy | pre-1.22 capture workaround | 1.22 | `forvar` |43| `any` | `interface{}` | 1.18 | `any` |44| `slices.Sort/Contains`, `slices.Collect`, `maps.Keys` | hand-rolled sort/contains/map loops | 1.21–1.23 | `slicescontains`, `slicessort`, `mapsloop` |45| `for i, v := range slices.Backward(s)` | `for i := len(s)-1; i >= 0; i--` | 1.23 | `slicesbackward` † |46| `strings.Cut` / `CutPrefix` / `CutSuffix` | `Index` + manual slicing | 1.18/1.20 | `stringscut`, `stringscutprefix` |47| `strings.SplitSeq` / `FieldsSeq` | ranging over `strings.Split`/`Fields` (allocates a slice) | 1.24 | `stringsseq` |48| `fmt.Appendf(b, …)` | `append(b, fmt.Sprintf(…)...)` / `[]byte(fmt.Sprintf(…))` | 1.19 | `fmtappendf` |49| `omitzero` on a struct-typed json field | `omitempty`, which does **nothing** for struct fields — a zero `time.Time` still marshals | 1.24 | `omitzero` |50| `t.Context()` in tests | `context.WithCancel(context.Background())` → `go-testing` | 1.24 | `testingcontext` |51| `reflect.TypeFor[T]()` | `reflect.TypeOf((*T)(nil)).Elem()` | 1.22 | `reflecttypefor` |52| `cmp.Or(a, b, …)` | nested `if x == "" { x = y }` | 1.22 | — |53| `sync.OnceFunc` / `OnceValue` | `sync.Once` + a captured var | 1.21 | — |54| `iter.Seq[V]` / range-over-func | `Visit(callback)` patterns, exposing slices | 1.23 | `stditerators` |55| `slog.LogAttrs(ctx, lvl, msg, attrs…)` on hot paths | key-value variadic `slog` (allocates) | 1.21 | — |56| `errors.Join` | manual multi-error concat → `go-errors` | 1.20 | — |57| `wg.Go(...)` | `wg.Add(1)`/`defer wg.Done()` → `go-concurrency` | 1.25 | `waitgroup` |58| `for b.Loop()` | `for i := 0; i < b.N; i++` → `go-testing` | 1.24 | `bloop` † |59| typed `atomic.Int64` | bare-int `atomic.Add*` → `go-concurrency` | 1.19 | `atomictypes` † |6061Idioms are a moving target — let the tool (pinned to the repo's toolchain) be the source of62truth so advice never drifts from the user's `go fix`. Go 1.26 also lifts the ban on a generic type63referencing itself in its own type-parameter list (e.g. `type Adder[A Adder[A]] interface{ Add(A) A }`),64so self-referential constraints no longer need a workaround — but that's a hand-written pattern, not65something a modernizer rewrites.6667## Modern, but no fixer automates it6869- **`os.OpenRoot(dir)` → `*os.Root`** (1.24) for anything that opens a caller-supplied path: its70 methods cannot escape the directory, including via symlink. Replaces `filepath.Join` plus71 hand-written `..`/prefix checks — the traversal-bug pattern those checks keep getting wrong.72- **`rand.Text()`** from `crypto/rand` (1.24) for tokens, nonces, and IDs — not `math/rand`, and not73 a hand-rolled base64 of `rand.Read`. Security-sensitive randomness always comes from `crypto/rand`.74- **`var s []T`, not `s := []T{}`** — the nil slice is the idiomatic empty slice (append works,75 `len` is 0). Reach for the non-nil literal only when something genuinely distinguishes them76 (e.g. marshalling `[]` vs `null`).77- **`slices.Sorted(maps.Keys(m))`** (1.23) when iterating a map for output — map order is random, and78 unstable output is a flaky-test and noisy-diff source.79- **A nested `:=` can shadow `err` or `ctx`.** `if v, err := f(); err != nil { … }` inside a function80 that already has an `err` declares a second one; the outer stays nil and a later `return err`81 reports success. Use `=` to assign into the existing variable, or give the inner one another name.82 Nothing in the standard set reports this; the `shadow` analyzer from `golang.org/x/tools` does83 when enabled (in golangci-lint: `linters.settings.govet.enable: [shadow]`) — it is noisy on84 legitimate reuse, so a repo enables it deliberately rather than by default.85- **No `break` at the end of a `switch` case.** Go cases do not fall through, so a `break` as the86 last statement of a case is dead text; `staticcheck` S1023 (standard set) flags exactly that.87 Earlier in a case a `break` still does work — it leaves the `switch` from that point — and a88 labelled `break` leaves the enclosing loop instead; neither of those is dead text.8990*Go 1.27 (released 2026-08-19, <https://go.dev/dl/>) graduates several † fixers into the toolchain's91`go fix` (`atomictypes`, `slicesbackward`, plus new `embedlit` and `unsafefuncs`), renames `waitgroup`92→ `waitgroupgo`, and drops `fmtappendf`; it also lands `encoding/json/v2` + `encoding/json/jsontext`93(v1 is reimplemented on v2, opt out with `GOEXPERIMENT=nojsonv2`) and `strings.CutLast`/`bytes.CutLast`.94Source: <https://go.dev/doc/go1.27>. See **Newer in Go 1.27** below for the hints these enable — none95of it is required on the 1.26 floor.*9697## Newer in Go 1.27 (hints, not requirements)9899Go 1.27 is additive over 1.26 — every 1.26 rule above still applies unchanged, and nothing here is100required while a module's `go` directive stays at 1.26. Once a repo's toolchain (and `go` directive)101moves to 1.27, these are worth reaching for.102103| Idiom (available from 1.27) | Supersedes / complements | Fixer / linter | Since | Source |104|---|---|---|---|---|105| Generic methods — a method may declare its own type parameters (interface methods still may not declare type parameters, nor be implemented by generic methods) | a package-level generic helper function bound to the receiver type as a workaround for "methods can't be generic" | — | 1.27 | [go.dev/doc/go1.27](https://go.dev/doc/go1.27) |106| `encoding/json/v2` + `jsontext` when you want v2's stricter semantics (invalid UTF-8 and duplicate object names rejected) or its `Options` | v1 `encoding/json` — **keep it**: it now runs on the v2 implementation underneath, keeps its behaviour (only exact error text may shift), gets the faster unmarshal for free, and stays supported. The release notes are explicit: "users are not required to migrate". Opt out of the new backend with `GOEXPERIMENT=nojsonv2` | — | 1.27 | [go.dev/doc/go1.27](https://go.dev/doc/go1.27) |107| `atomictypes` — graduates into the stock `go fix` (previously † golangci-lint-only, row above) | raw `sync/atomic` functions | `atomictypes` | 1.27 | [go.dev/doc/go1.27](https://go.dev/doc/go1.27) |108| `slicesbackward` — graduates into the stock `go fix` (previously † golangci-lint-only, row above) | `for i := len(s)-1; i >= 0; i--` | `slicesbackward` | 1.27 | [go.dev/doc/go1.27](https://go.dev/doc/go1.27) |109| `embedlit` — initialise a field promoted from an embedded struct directly in the parent literal: `T{U: U{x: 1}}` → `T{x: 1}` | the nested literal an embedded struct used to require | `embedlit` | 1.27 | [modernize](https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize) |110| `unsafefuncs` — `unsafe.Pointer(uintptr(ptr) + uintptr(n))` → `unsafe.Add(ptr, n)` | hand-rolled unsafe pointer arithmetic (`unsafe.Add` itself is 1.17; the fixer is new) | `unsafefuncs` | 1.27 | [modernize](https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize) |111112- **`go test` runs the `stdversion` vet check by default from 1.27.** The check itself is not new —113 what changes is that it becomes automatic. It flags stdlib symbols newer than the `go` directive in114 force for the file, which is the guardrail that keeps a 1.26-floor module from silently depending on115 a 1.27-only symbol. Trust it over manual review for this. Source:116 [go.dev/doc/go1.27](https://go.dev/doc/go1.27).117118## Sources119- `modernize` (per-fixer docs, the Fixer column) — <https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize>120- `go fix` (rewritten in 1.26) — <https://go.dev/blog/gofix>; range-over-func — <https://go.dev/blog/range-functions>121- `slog` — <https://go.dev/blog/slog>; Go 1.21–1.26 release notes (`new(expr)`, self-ref generics — <https://go.dev/doc/go1.26>)122- `os.Root` / `omitzero` / `rand.Text` — <https://go.dev/doc/go1.24>; Code Review Comments (Declaring Empty Slices, Crypto Rand) — <https://go.dev/wiki/CodeReviewComments>123- Go 1.27 release notes (generic methods, `stdversion`, `encoding/json/v2`, new `go fix` modernizers) — <https://go.dev/doc/go1.27>124- Google Go Style Decisions (Switch and break, Nil slices) — <https://google.github.io/styleguide/go/decisions>; Best Practices (Shadowing) — <https://google.github.io/styleguide/go/best-practices>; staticcheck S1023 — <https://staticcheck.dev/docs/checks/#S1023>; `shadow` analyzer — <https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/shadow>125- `atomictypes` / `slicesbackward` / `embedlit` modernizer commits — <https://github.com/golang/tools/commit/17ee9acf0e54b52b93b8250245ea261f5e4a88ec>, <https://github.com/golang/tools/commit/b96d2a55a08943af1de2914a59fb88fe0acbb897>, <https://github.com/golang/tools/commit/c2a9c879aa8aea10399b942692b75107790bbcd7>126127---128*Decomposition inspired by [`samber/cc-skills-golang`](https://github.com/samber/cc-skills-golang) (MIT © 2026 Samuel Berthe); rules grounded in the sources above.*