Languages No. 107

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.

  • namego-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.

~/.claude/skills/go-idioms/SKILL.md

--- name: go-idioms description: Writes and reviews idiomatic Go — small consumer-side interfaces, wrapped errors with errors.Is/As, generics only where they remove… license: MIT ---

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 _ = err without 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.Context is 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
  • main calls run(ctx, os.Args, os.Getenv, os.Stdout) error and exits non-zero on error; run is testable.
  • Use internal/ by default. Only create pkg/ 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 %w when callers may need the cause; %v to 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.Join to 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 genericsDo not
Container/algorithm over many element types (Map, Filter, sets, caches)One concrete type exists today
Removing near-identical functions differing only by typeReplacing 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 select with <-ctx.Done().
  • for v := range ch ends only when the channel is closed — make sure someone closes it.
NeedUse
Wait for N goroutines, errors mattererrgroup.Group
Wait, no errorssync.WaitGroup (wg.Go(func(){...}) in Go 1.25+)
Protect a map/structsync.Mutex held for short sections; sync.RWMutex only if reads dominate and measured
Counter/flagatomic.Int64, atomic.Bool
One-time initsync.OnceValue / sync.OnceValues (1.21)
Pipeline stageschannels + context
Limit concurrencyg.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.Cleanup for 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.Diff when the project already uses it; otherwise reflect.DeepEqual is fine for small cases.
  • HTTP handlers: httptest.NewRecorder / httptest.NewServer.
  • Benchmarks: for b.Loop() { ... } (1.24) instead of for i := 0; i < b.N; i++.
  • Fuzz parsers: func FuzzParse(f *testing.F) and run go test -fuzz=FuzzParse -fuzztime=30s.

Style points reviewers flag

  • Early return; keep the happy path at the left margin. No else after return.
  • Short receiver names (s *Store), consistent across methods; no this/self.
  • Acronyms keep case: userID, HTTPClient, ParseURL.
  • Doc comments start with the name: // Store persists users.
  • defer cleanup 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, not fmt.Sprintf messages.

Gotchas

  • Nil interface vs. interface holding a nil pointer: returning a typed *MyErr(nil) as error is non-nil. Return a literal nil.
  • 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.Clone or full slice expressions s[:n:n] when handing slices out.
  • defer in a loop runs at function end, not iteration end; extract a function.
  • http.DefaultClient has no timeout. Construct &http.Client{Timeout: 10 * time.Second}.
  • JSON: unexported fields are ignored silently; omitempty does not omit zero structs (use omitzero, 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.

esc