Go 2025-2026 Conventions — What the Best Projects Do

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:

  • slog used throughout
  • Go 1.22 stdlib routing — no gorilla/mux
  • modernc.org/sqlite zero-CGO
  • Interface-based design on stores
  • errors.Is for sentinels
  • Context propagated to all I/O operations

Room for improvement:

  • nil, nil for "not found" in the store → should be nil, ErrJobNotFound
  • Tests not parallel — t.Parallel() missing everywhere
  • govulncheck absent from CI/Makefile
  • context.WithoutCancel for 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.

Comments (0)