Go Idioms skill for Claude Code
Go that passes review on the first round. Claude writes Go the way experienced Go reviewers expect: consumer-side interfaces, errors wrapped with context and checked via errors.Is and errors.As, generics only where they remove real duplication, and goroutines that always have an exit path through context and errgroup. Tests are table-driven and every change is checked with vet, staticcheck and the race detector.
- name
go-idioms - size232 lines · ≈2,250 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Writes and reviews idiomatic Go — small consumer-side interfaces, wrapped errors with errors.Is/As, generics only where they remove duplication, goroutines with context cancellation and errgroup, channel ownership rules, table-driven tests, and go vet, staticcheck, and race detector checks. Use when writing Go code, reviewing a Go PR, fixing goroutine leaks or data races, designing package APIs, or when code reads like Java or Python translated into Go. Try asking
- Review this Go package for non-idiomatic patterns and goroutine leaks
- Fetch these 500 URLs concurrently with at most 10 in flight and fail fast on error
- Refactor these error checks so callers can tell not-found from other failures
Use it when
- Writing or reviewing Go code
- go test -race reports a data race or goroutines leak
- Designing errors, interfaces, or package boundaries
- Deciding whether generics are worth it
Skip it when
- Building an HTTP service with routing, middleware and deployment — use go-services
- Tuning Postgres queries the Go code issues — use sql-query-tuning
How to check the result
gofmt reports no files, go vet and staticcheck are clean, and go test -race -count=1 ./... passes.
The common mistake it prevents
Starting goroutines that send on a channel nobody drains after an early error return, which leaks one goroutine per failed request.
Done well means: code a Go reviewer would accept without style comments — short
functions, errors handled on the line they occur with context added, every goroutine has
a clear exit, go vet, staticcheck, and go test -race pass, and the package API is
small enough to explain in its doc comment.
Ground rules
- Accept interfaces, return concrete types. Define interfaces in the package that uses them, with one to three methods.
- Every error is handled, wrapped with context (
fmt.Errorf("load user %d: %w", id, err)), or returned. Never_ = errwithout a comment. - Every goroutine you start must have a way to stop (context, closed channel, or bounded work) and someone must wait for it.
context.Contextis the first parameter of anything that does I/O or may block. Never store it in a struct.- Zero values should be useful. Avoid constructors that only set defaults.
- No package named
util,common,helpers,models. Name packages for what they provide.
Before writing, inspect
go version; grep -E '^(go|toolchain) ' go.mod
go vet ./... && staticcheck ./... 2>/dev/null
ls internal cmd pkg 2>/dev/null
Match the module’s Go version: range-over-func iterators need 1.23, for i := range 10
needs 1.22, per-iteration loop variables need go 1.22 in go.mod, min/max builtins
1.21, slices/maps packages 1.21, testing/synctest 1.25.
Project layout
cmd/myapp/main.go # thin: parse flags/env, wire deps, call run(ctx)
internal/billing/ # domain packages, not importable from outside the module
internal/platform/db/ # infrastructure adapters
maincallsrun(ctx, os.Args, os.Getenv, os.Stdout) errorand exits non-zero on error;runis testable.- Use
internal/by default. Only createpkg/if you truly publish packages.
Errors
var ErrNotFound = errors.New("not found") // sentinel: callers compare with errors.Is
type ValidationError struct{ Field, Reason string } // typed: callers extract with errors.As
func (e *ValidationError) Error() string { return e.Field + ": " + e.Reason }
func (s *Store) User(ctx context.Context, id int64) (*User, error) {
u, err := s.q.GetUser(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("user %d: %w", id, ErrNotFound)
}
if err != nil {
return nil, fmt.Errorf("get user %d: %w", id, err)
}
return u, nil
}
// caller
var ve *ValidationError
switch {
case errors.Is(err, ErrNotFound): // 404
case errors.As(err, &ve): // 400 with ve.Field
}
- Wrap with
%wwhen callers may need the cause;%vto deliberately hide it at an API boundary. - Error strings: lowercase, no trailing punctuation, no “failed to” chains — the wrap
prefixes form a readable path:
load config: open /etc/app.yaml: no such file. errors.Jointo return multiple errors (validation, cleanup).- Panic only for programmer errors (impossible states), never for input or I/O.
Interfaces
// in package notify (the consumer), not in package email
type Sender interface {
Send(ctx context.Context, to, body string) error
}
- Do not create an interface until you have two implementations or need a test seam.
- Do not return interfaces from constructors; return
*Client. - Assert implementation at compile time when it matters:
var _ Sender = (*SMTPClient)(nil).
Generics: when to use
| Use generics | Do not |
|---|---|
Container/algorithm over many element types (Map, Filter, sets, caches) | One concrete type exists today |
| Removing near-identical functions differing only by type | Replacing an interface whose methods are the point |
Type-safe wrappers (atomic.Pointer[T], sync.Pool helpers) | To mimic inheritance hierarchies |
Check slices, maps, and cmp before writing helpers: slices.SortFunc,
slices.Contains, slices.Index, maps.Keys (returns an iterator in 1.23+),
cmp.Compare, cmp.Or.
Concurrency
Default tool: errgroup with context for “run N things, fail fast, wait for all”.
import "golang.org/x/sync/errgroup"
func fetchAll(ctx context.Context, urls []string) ([][]byte, error) {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8) // bounded concurrency
out := make([][]byte, len(urls)) // each goroutine writes its own index: no lock needed
for i, u := range urls {
g.Go(func() error {
b, err := fetch(ctx, u)
if err != nil {
return fmt.Errorf("fetch %s: %w", u, err)
}
out[i] = b
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return out, nil
}
Channel rules:
- The sender owns the channel and is the only one who closes it. Never close from the receiver; never close twice.
- Unbuffered by default. A buffer size needs a reason (known burst size, semaphore).
- Every blocking send/receive in a goroutine sits in a
selectwith<-ctx.Done(). for v := range chends only when the channel is closed — make sure someone closes it.
| Need | Use |
|---|---|
| Wait for N goroutines, errors matter | errgroup.Group |
| Wait, no errors | sync.WaitGroup (wg.Go(func(){...}) in Go 1.25+) |
| Protect a map/struct | sync.Mutex held for short sections; sync.RWMutex only if reads dominate and measured |
| Counter/flag | atomic.Int64, atomic.Bool |
| One-time init | sync.OnceValue / sync.OnceValues (1.21) |
| Pipeline stages | channels + context |
| Limit concurrency | g.SetLimit(n) or a buffered channel semaphore |
Goroutine leak shapes to look for in review: a goroutine sending to a channel nobody
reads after an early return; time.After in a long-lived loop (use time.NewTimer
and Stop, or rely on 1.23 timer GC but still prefer explicit timers); missing
resp.Body.Close().
Testing
func TestParseAmount(t *testing.T) {
tests := []struct {
name string
in string
want int64
wantErr bool
}{
{"whole", "12", 1200, false},
{"cents", "12.34", 1234, false},
{"negative", "-1", 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := ParseAmount(tt.in)
if (err != nil) != tt.wantErr {
t.Fatalf("ParseAmount(%q) err = %v, wantErr %v", tt.in, err, tt.wantErr)
}
if got != tt.want {
t.Errorf("ParseAmount(%q) = %d, want %d", tt.in, got, tt.want)
}
})
}
}
- Failure messages:
Func(input) = got, want want. t.Helper()in helpers,t.Cleanupfor teardown,t.TempDir()for files,t.Context()(1.24) for a context canceled at test end.- Compare structs with
github.com/google/go-cmp/cmp.Diffwhen the project already uses it; otherwisereflect.DeepEqualis fine for small cases. - HTTP handlers:
httptest.NewRecorder/httptest.NewServer. - Benchmarks:
for b.Loop() { ... }(1.24) instead offor i := 0; i < b.N; i++. - Fuzz parsers:
func FuzzParse(f *testing.F)and rungo test -fuzz=FuzzParse -fuzztime=30s.
Style points reviewers flag
- Early return; keep the happy path at the left margin. No
elseafterreturn. - Short receiver names (
s *Store), consistent across methods; nothis/self. - Acronyms keep case:
userID,HTTPClient,ParseURL. - Doc comments start with the name:
// Store persists users. defercleanup immediately after acquiring:f, err := os.Open(p); if err != nil {...}; defer f.Close().- Pointer receivers if any method mutates or the struct is large; do not mix on one type.
- Structured logging with
log/slog, passing key-value pairs, notfmt.Sprintfmessages.
Gotchas
- Nil interface vs. interface holding a nil pointer: returning a typed
*MyErr(nil)aserroris non-nil. Return a literalnil. - Maps are not safe for concurrent writes; the runtime crashes with
concurrent map writes. - Appending to a slice that shares a backing array with another can overwrite data; use
slices.Cloneor full slice expressionss[:n:n]when handing slices out. deferin a loop runs at function end, not iteration end; extract a function.http.DefaultClienthas no timeout. Construct&http.Client{Timeout: 10 * time.Second}.- JSON: unexported fields are ignored silently;
omitemptydoes not omit zero structs (useomitzero, Go 1.24).
Verification
gofmt -l . | tee /dev/stderr | (! read) # no unformatted files
go vet ./...
staticcheck ./... # or golangci-lint run
go test -race -count=1 ./...
govulncheck ./... # for dependency changes
All must pass. A race report is a bug even if tests pass; fix it before anything else.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.