Go 1.21, 1.22, 1.23, 1.24 — four releases in two years, each adding something that genuinely changes how you write Go. Not flashy syntax features, but tools and patterns that eliminate recurring pain points. The result: serious codebases in 2025 look noticeably different from 2022.
What this article covers: what has settled as standard, with concrete examples from real projects (Caddy, Tailscale, pgx v6). And what good projects have stopped using.
slog — the logger you don't need to install
Since Go 1.21, slog is in the stdlib. Logrus is dead, zap is on life support,
and projects still importing github.com/sirupsen/logrus in 2025 just haven't
cleaned up yet.
The pattern that has taken hold in serious codebases: an enriched logger per operation, not a global logger. This gives structured logs without repeating the same fields on every call:
// Established pattern: logger enriched with operation context
func processJob(ctx context.Context, jobID, model string) {
log := slog.With("job_id", jobID, "model", model)
log.Info("starting")
// ... processing
log.Error("failed", "error", err)
}
// In libraries: never call slog.SetDefault()
// That's the role of main.go — a lib that calls SetDefault() overwrites
// the config of the application using it
Tailscale has migrated to slog. Caddy uses it for its plugins. That's the signal that the ecosystem has decided.
errors — systematic wrapping and sentinels
fmt.Errorf with %w has been the standard since Go 1.13 (2019).
In 2025, if you still see code returning a bare error without context, that's technical
debt. errors.Join (Go 1.20) fixed multi-errors without a third-party library.
// fmt.Errorf with %w — systematic wrapping, no exceptions
return fmt.Errorf("create job: %w", err)
// errors.Join for multi-field validation
errs := []error{}
errs = append(errs, validatePrompt(r.Prompt))
errs = append(errs, validateModel(r.Model))
return errors.Join(errs...) // nil if all nil, combined otherwise
Sentinel errors matter for "not found" cases — but you have to actually use them:
var ErrJobNotFound = errors.New("job not found")
// ❌ Anti-pattern still too common in 2024
func (s *Store) Get(id string) (*Job, error) {
// ...
return nil, nil // absent job = nil, nil ← ambiguous, impossible to test cleanly
}
// ✅ 2025 — explicit and testable with errors.Is
func (s *Store) Get(id string) (*Job, error) {
// ...
return nil, ErrJobNotFound
}
// Caller side — clean, no string comparison
if errors.Is(err, ErrJobNotFound) {
return http.StatusNotFound, nil
}
ClaudeGate still has a few nil, nil cases that should be
nil, ErrJobNotFound. That's exactly the kind of debt that makes
tests brittle.
context — the 10-second discipline
Context has been non-negotiable for a long time. What changed with Go 1.21:
context.WithoutCancel. An addition that fixes a real problem we used to
work around with context.Background() — at the cost of losing all
parent context values.
// context.WithoutCancel (Go 1.21) — for fire-and-forget operations
// Exact use case: webhooks, notifications, background tasks
go webhook.Send(context.WithoutCancel(ctx), url, payload)
// Without this: if the HTTP context expires, the webhook is cancelled before it's sent
// Before Go 1.21: context.Background() — loses context values (trace ID, user ID, etc.)
// Timeout on all external operations — no exceptions
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
In ClaudeGate, webhooks still use the original HTTP request context. If the request
times out at 30 seconds and the webhook takes 35 seconds to respond, the webhook
is silently cancelled. context.WithoutCancel fixes that.
iter.Seq2 — native iterators, finally
Go 1.23 introduced the iter package and "range over functions".
This is probably the most underrated change in recent releases. pgx v6 and sqlc
have already adopted it for database cursors.
// Before Go 1.23 — verbose and error-prone
rows, err := s.db.QueryContext(ctx, `SELECT id, status, prompt FROM jobs`)
if err != nil {
return fmt.Errorf("query jobs: %w", err)
}
defer rows.Close()
for rows.Next() {
var j Job
if err := rows.Scan(&j.ID, &j.Status, &j.Prompt); err != nil {
return fmt.Errorf("scan job: %w", err)
}
jobs = append(jobs, j)
}
// Go 1.23+ — clean, lazy, composable pattern
func (s *Store) Jobs(ctx context.Context) iter.Seq2[*Job, error] {
return func(yield func(*Job, error) bool) {
rows, err := s.db.QueryContext(ctx, `SELECT id, status, prompt FROM jobs`)
if err != nil {
yield(nil, err)
return
}
defer rows.Close()
for rows.Next() {
j := &Job{}
if err := rows.Scan(&j.ID, &j.Status, &j.Prompt); err != nil {
yield(nil, err)
return
}
if !yield(j, nil) {
return // caller did break — clean stop
}
}
}
}
// Usage — readable, explicit error handling
for j, err := range store.Jobs(ctx) {
if err != nil {
return fmt.Errorf("list jobs: %w", err)
}
// process j
}
The benefit isn't limited to SQL queries. Anything that used to be a slice loaded entirely into memory can become a lazy iterator. For large collections, that's the difference between 2 MB and 2 KB of RAM.
testing — synctest and the end of time.Sleep
Go 1.24 added testing/synctest. If you've ever written a test with
time.Sleep(100 * time.Millisecond) to "give the goroutine time to finish",
you know that's cargo-culting — it makes tests flaky on slow machines and too slow
on fast ones.
// ❌ Before — hacky sleep, flaky by nature
go worker.processJob(ctx, "job-1")
time.Sleep(100 * time.Millisecond)
assert.Equal(t, "completed", store.GetStatus("job-1"))
// ✅ Go 1.24 — synctest controls virtual time
synctest.Run(func() {
go worker.processJob(ctx, "job-1")
synctest.Wait() // waits until all goroutines are blocked or done
// assertions are now deterministic
assert.Equal(t, "completed", store.GetStatus("job-1"))
})
The other two patterns that have taken hold in 2025:
// Parallel subtests — systematic on table-driven tests
func TestValidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
wantErr bool
}{
{"valid", "claude-3-5-sonnet", false},
{"empty", "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := validateModel(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("got %v, want err=%v", err, tt.wantErr)
}
})
}
}
// t.Cleanup systematic — cleaner than defer in TestXxx
store := newTestStore(t)
t.Cleanup(func() { store.Close() })
ClaudeGate doesn't have t.Parallel() on its unit tests yet.
It's quick to add and often reveals race conditions that would have gone unnoticed.
go:generate — mocks without bikeshedding
The mock library wars are over in practice. mockery v3 with //go:generate
has become the de facto standard for projects that need mocks. The testify vs stdlib
testing debate is less settled — new projects often start with the stdlib.
//go:generate mockery --name=JobStore --output=mocks --outpkg=mocks
type JobStore interface {
Get(ctx context.Context, id string) (*Job, error)
Save(ctx context.Context, job *Job) error
}
go generate ./...
The real benefit of //go:generate: mocks are versioned with the code.
No external script to maintain, no "I forgot to regenerate". Running
go generate in CI verifies the mocks are up to date.
go tool — tooling dependencies in go.mod
Go 1.24 officially blessed something we'd been hacking around for years with empty
tools.go files. The tool directive in go.mod
versions dev tools with the project:
// go.mod
tool (
github.com/golangci/golangci-lint/cmd/golangci-lint v1.62.0
golang.org/x/vuln/cmd/govulncheck v1.1.3
)
// No more curl | sh in Makefiles
go tool golangci-lint run
go tool govulncheck ./...
The concrete benefit: everyone uses the same version of golangci-lint. No more "it passes locally" because someone has v1.55 and CI has v1.62.
What the best projects have stopped using
The hallmark of a serious Go codebase in 2025: the number of direct dependencies
in go.mod. Good projects have 3 to 5 direct dependencies, not 30.
| Abandoned / avoided | Replaced by |
|---|---|
github.com/pkg/errors |
stdlib errors + fmt.Errorf("%w") |
testify/assert in new projects |
stdlib testing |
logrus, zap |
slog |
gorilla/mux |
net/http stdlib routing (Go 1.22) |
cobra for small CLIs |
flag + manual subcommands |
| Complex Docker multi-stage builds | ko for Go or docker buildx |
The underlying trend: Go has caught up on HTTP routing (Go 1.22), logging (Go 1.21), iterators (Go 1.23). What was a legitimate excuse for third-party dependencies three years ago no longer is.
ClaudeGate scorecard
As a concrete example, here's where ClaudeGate stands against these conventions:
Well aligned:
slogused throughout- Go 1.22 stdlib routing — no gorilla/mux
modernc.org/sqlitezero-CGO- Interface-based design on stores
errors.Isfor sentinels- Context propagated to all I/O operations
Room for improvement:
nil, nilfor "not found" in the store → should benil, ErrJobNotFound- Tests not parallel —
t.Parallel()missing everywhere govulncheckabsent from CI/Makefilecontext.WithoutCancelfor fire-and-forget webhooks- golangci-lint tooling not versioned via
go tool
Conclusion
The real 2025 trend: the best Go projects have a go.mod with 3 to 5
direct dependencies maximum. The stdlib-first philosophy has come back strong since
Go 1.22 fixed HTTP routing, Go 1.21 gave us slog, and Go 1.23 gave us
iterators. That's not a coincidence — it's the signal that the Go stdlib is finally
mature for most use cases.
What's striking in projects like Tailscale or Caddy is the absence of "utility" dependencies. No lodash-for-Go, no retry library, no validation framework. Just idiomatic Go with the stdlib — and complex cases handled directly, without intermediate abstractions.
For existing projects: no need to migrate everything at once. Start with
slog (a day's work on an average codebase), add govulncheck
to CI (30 minutes), and introduce t.Parallel() on new tests.
The rest follows naturally.