Simplicity is the highest Go virtue. Three biases decide most calls:
- Resist abstraction until the cost of not abstracting is proven. An interface, a type parameter, or a layer added in anticipation of a second caller is a defect.
- Reach for the standard library first. A dependency earns its place by doing what the standard library does not.
- The
godirective decides what may be written, not the installed toolchain.
Language Version
The go directive in go.mod sets the language version a module may use. Read it before writing code and never reach
for a feature above it. The stdversion analyzer reports a standard-library symbol newer than the directive: Go 1.27
and later fail go test on it, earlier toolchains report it under go vet.
Floor version per feature. Anything older than 1.21 is available in every supported module.
- 1.21 —
min,max,clearbuiltins;slices,maps,cmp,log/slog;context.WithoutCancel;errors.ErrUnsupported;sync.OnceValue - 1.22 — per-iteration loop variables;
for range int;math/rand/v2; method and wildcard patterns innet/http.ServeMux - 1.23 — range-over-func iterators;
iter; iterator functions inslicesandmaps;unique; unbuffered timer channels - 1.24 — generic type aliases;
os.Root;testing.B.Loop;t.Context;runtime.AddCleanup;omitzerostruct tag;tooldirectives; iterator functions instrings - 1.25 —
testing/synctest;sync.WaitGroup.Go; container-awareGOMAXPROCS - 1.26 —
new(expr); self-referential type constraints;errors.AsType;go fixmodernizers - 1.27 — generic methods; field-selector keys in struct literals;
encoding/json/v2;uuid;strings.CutLast
Read [${CLAUDE_SKILL_DIR}/references/versions/go1.NN.md] — one file per version, go1.21.md through go1.27.md —
when writing against a feature near the module's floor, and whenever raising the directive. Each carries what its
version added, which behavior changes the go directive gates, and the traps it introduced.
Naming
- Never
util,common,misc,shared,helpers, ortypesas a package name. Name a package for what it provides; one that resists naming is holding unrelated things. - Do not stutter. The package name prefixes every qualified reference:
widget.New(), neverwidget.NewWidget(). - Go source files are kebab-case:
user-service.go,http-handler.go.
Interfaces
- Define an interface where it is consumed, never where it is implemented. Producers return concrete types.
- Never define an interface for mocking. Wait for a second implementation or a real consumer boundary.
- Assert satisfaction at compile time where the contract matters:
var _ http.Handler = (*Handler)(nil).
Generics
- Write the concrete version first. Add type parameters when two or more implementations differ only by type — not in anticipation of a second caller.
- Constrain by what the body uses.
cmp.Orderedwhen it compares,anywhen it only moves values. A constraint listing methods the function never calls is noise. - Prefer a type parameter to
anyplus a type switch when callers know the type. It moves the failure to compile time and removes the assertion. - A function generic in a type it passes through unchanged is a function on
any. Drop the parameter. - Methods may declare their own type parameters — put a generic operation in the type's namespace when it belongs there rather than the package's. Interface methods cannot be generic, so a generic method never satisfies an interface.
Receivers
- Never mix pointer and value receivers on one type. Default to pointer; take a value receiver only for a small immutable value type.
- Never take a pointer to a map, func, or channel — they are already reference types.
Context
- Never store a
context.Contextin a struct or an option struct. Pass it as the first parameter. context.Background()only inmain()and test setup.- Never hand a request context to background work — it is canceled when the response is sent. Use
context.WithoutCancel(r.Context()).
Declarations
- Nil slices are valid and preferred:
var s []string, tested withlen(s) == 0. Use[]string{}only where the JSON encoding must be[]rather thannull. Pre-allocate when the size is known:make([]T, 0, n). &T{}for a pointer to a struct,new(expr)for a pointer to any other value —new(expr)replaces the hand-rolledptr(v)helper.- Start
iotaat 1 unless the zero value carries meaning:const ( StatusActive Status = iota + 1; ... ). - Name result parameters only where they document a caller obligation, never to enable a naked return.
Error Handling
- Handle an error once — log it or return it, never both. Logging and returning reports one failure at every level of the stack. If you log, degrade and stop; if you return, wrap and let the caller decide.
- Wrap with context the underlying error does not already carry:
fmt.Errorf("get user: %w", err). Lowercase, no trailing punctuation, no "failed to" prefix,%wlast so the chain reads newest to oldest. - Use
%vinstead of%wwhen the wrapped error is an implementation detail —%wcommits your API to the dependency that produced it. - Match with
errors.Isanderrors.As, never==or a bare type assertion.errors.AsType[T]is the generic form and needs no pre-declared target. - Wrap a sentinel before returning it so callers cannot match it with
==. errors.Joinonly where every operation must be attempted — closing a set of resources, validating every field. Never as a substitute for returning at the first failure on a sequential path.- Never silently drop an error from a deferred call. Propagate a close error when no prior error exists; write
_ = f.Close()where the drop is deliberate. MustXxxpanics only in package-level initialization and test helpers, never in a request path.- Prefer the project's structured error package —
ErrNotFound.Wrap(err),e.NewFrom("context", err)— overfmt.Errorfwherever one is in use, and use it for every error in that project.
Read [${CLAUDE_SKILL_DIR}/references/conventions/errors.md] when the project uses golib/e, when designing a new error
type, or when a package needs internal panic/recover — it carries the golib/e API, the creation decision tree, and the
recover-at-the-boundary pattern.
Gotchas
:=in an inner block silently shadows the outer variable,errmost often. Assign with=when the target is an outer variable.deferevaluates its arguments immediately. Capture later values in a closure:defer func() { notify(s) }().deferfires at function return, not at the end of a loop iteration. Extract the loop body into a function.appendto a slice with spare capacity mutates the shared backing array, and every slice over that array sees it. Cap withs[:len(s):len(s)]or copy.- A nil pointer in an interface is not nil. Return an explicit
nilfrom a function whose result type is an interface, never a typed nil. - Never copy a
sync.Mutexor a struct holding one. Copy slices and maps at API boundaries so callers cannot mutate your state. - Prefer
int. Sized integer types overflow silently, and only a protocol, a binary format, or a measured constraint justifies one. +=in a loop is O(n²). Usestrings.BuilderwithGrow.- Name an inverted condition instead of commenting it:
if cacheHit := err == nil; cacheHit {. A name is read at the branch and cannot go stale.
Read [${CLAUDE_SKILL_DIR}/references/conventions/gotchas.md] when one of these bites and the obvious fix does not hold
— it carries an annotated reproduction and fix for each.
Concurrency
- Every goroutine needs an exit condition and a way to wait for it. No fire-and-forget: a goroutine nobody joins is a leak with a race attached.
- No goroutines in
init(). Spawn them from a constructor that owns the lifecycle. context.Contextis the default cancellation mechanism. Explicit stop and done channels belong only in code that predates it.wg.Go(f)overwg.Add(1)plusdefer wg.Done()— the pair is where counter mistakes come from.errgroup.WithContextover a hand-rolled WaitGroup and error collection.- Channel buffer is zero or one. A larger buffer requires knowing what stops it from filling.
- Declare channel direction in signatures (
<-chan,chan<-) and close only from the sending side. selectpicks a ready case at random, not in source order. Drain the work channel after the stop signal where priority matters.- A nil channel blocks forever — assign
nilto a channel to remove its case from aselectat runtime. - Mutexes are named fields, never embedded, never copied.
defer mu.Unlock()unless nanoseconds are measured. Reach foratomic.Boolandatomic.Int64for flags and counters. fmt.Errorf("%v", obj)can deadlock — it may callobj.String(), which may take the mutex you already hold. Format from fields you have already read.- Clone with
maps.Cloneinside the critical section. Assigning a map or a slice shares the backing storage, so both variables see every write.
Read [${CLAUDE_SKILL_DIR}/references/conventions/concurrency.md] when building a pipeline, a fan-out, a fan-in, or a
bounded worker pool — it carries the full pattern for each and the worker-with-lifecycle template.
Testing
- Table tests: slice named
tests, case namedtt, inputs prefixedgive, expectations prefixedwant, every case undert.Runwith a descriptive name. Every row must exercise every field. - Split into separate test functions where cases need different setup, conditional assertions, or per-case mock configuration. A table whose fields only some rows use is two tests.
requirefor anything that makes the rest of the test meaningless — error checks, nil guards.assertfor independent value checks.require.Equalfor structs and slices, neverreflect.DeepEqual.- Never match an error by message text. Use
errors.Isorerrors.As, andrequire.ErrorContainsonly where no sentinel exists. t.Fatalonly from the goroutine running the test. Uset.Errorfand return in a spawned goroutine.t.Parallel()does not spawn one, sot.Fatalstays safe in a parallel subtest.t.Helper()in setup helpers, never in an assertion wrapper — there it hides the line that failed.- Use the self-undoing scoped helpers:
t.TempDir(),t.Setenv(),t.Chdir(),t.Context(),t.Cleanup(). A test that writes into its source directory or mutates the environment poisons the build cache, which keys on both. - Never
time.Sleep. For behavior that depends on time, run the test in asynctestbubble:synctest.Test(t, func(t *testing.T) { ... })gives it a fake clock that jumps forward the moment every goroutine in the bubble blocks, so a one-hour timeout resolves instantly.synctest.Waitblocks until the others are blocked. -raceon every run of concurrent code, locally and in CI.- Prefer real service instances to mocks — databases, caches, brokers. Gate the slow ones behind an environment variable and skip when it is unset.
- Never assert on serialized output. Parse and compare semantically;
json.Marshalfield order is not a contract. - Naming:
Test_TypeNamefor type-level tests, witht.Runper method or scenario. Black-boxpackage foo_testby default;package fooinfoo_internal_test.gowhere internals must be reached. Benchmarks infoo_benchmark_test.go. - Test doubles live in a package named for their subject plus
test—creditcardtest— with plain type names (Stub,Fake) where one type is doubled and prefixed names (StubService) where several are. for b.Loop()for a benchmark loop. The body runs once per-count, so setup outside it runs once, and arguments and results stay alive without a package-level sink.
Read [${CLAUDE_SKILL_DIR}/references/conventions/testing.md] when writing the first test in a package, or when a test
needs httptest or iotest — it carries the table-test template, the parallel-with-teardown shape, and the utility
catalog.
Project Structure
internal/by default. Everything not deliberately part of the public API goes there, where it can be refactored without breaking anyone.cmd/<name>/per binary, holdingpackage mainand nothing else.- Start flat. Add a directory when a package needs helpers of its own, a second command appears, or a sub-package serves a distinct importable purpose — never in anticipation.
- Two import groups — standard library, then everything else — separated by a blank line. Alias only to resolve a
collision. Blank imports in
mainpackages and tests only; dot imports in test files only. - Order a file by call order: types and constants, constructor, exported methods grouped by receiver, unexported methods, helpers. Callers before callees.
- Break compatibility in three commits — add the new form, migrate the callers, delete the old form. Never combine a breaking change with new functionality.
Read [${CLAUDE_SKILL_DIR}/references/conventions/structure.md] when creating a package or making a
backward-incompatible change — it carries the staged workflow and the package-naming examples.
Configuration
For a constructor with three or more optional parameters, pick one shape and hold it across the package.
- Option struct where most callers set several options, or the options are shared across functions.
- Functional options where most callers set none, the option count is large, options need validation, or third
parties must extend the set. Use the interface form,
type Option interface{ apply(*options) }, and give every option a parameter:rpc.FailFast(true), neverrpc.EnableFailFast().
Read [${CLAUDE_SKILL_DIR}/references/conventions/idioms.md] when the choice is contested, or when a naming,
declaration, or embedding decision needs a worked example — it carries the decision table across both shapes.
Types and Zero Values
- Design the zero value to be useful.
var buf bytes.Bufferis ready to write to. Add a constructor only where a non-zero default is required. - Embed only where the promoted method set is the API you intend. Embedding in an exported struct commits you to every exported method of the inner type, including the ones it has not added yet. Use a named field otherwise.
- A type alias earns its place by adding meaning:
type UserID stringdoes,type MyString stringdoes not. Runblocks until the work is done;Startreturns immediately and owns a goroutine.Starttakes a context first.
Standard Library
slicesandmapsover hand-written loops.slices.Contains,slices.SortFunc,slices.BinarySearch,slices.Compact,maps.Clone,maps.DeleteFunc. A loop reimplementing one of these is a defect, not a style choice. Comparison helpers come fromcmp.log/slogfor structured logging. Reservelogfor a binary's fatal path.- Iterators for sequences. Return
iter.Seq[V]oriter.Seq2[K, V]where the caller consumes elements one at a time and the whole set need not exist at once; return a slice where it is small and already materialized. Collect withslices.Collect,slices.Sorted, ormaps.Collect—maps.Keysyields an iterator, so the ordered key slice isslices.Sorted(maps.Keys(m)). - Writing an iterator: stop as soon as
yieldreturns false, and release resources withdeferinside the iterator function. os.Rootfor any path that comes from outside the program. Its methods refuse to escape the directory, symlinks included.filepath.Cleandoes not.math/rand/v2overmath/randfor non-cryptographic randomness;crypto/randwhere a value must be unpredictable.
Documentation
- Every exported symbol gets a doc comment starting with its name, in complete sentences. Package documentation
lives in
doc.go. - Unexported symbols get none by default. No external caller reads them, so non-obvious behavior there is a naming or shape problem first.
- Update the doc comment in the edit that changes the symbol, not afterwards.
Global State
- A library never forces global state. Expose an instance API; a package-level convenience API is a thin proxy to an instance and belongs only in a binary.
- Global state is acceptable only where it is logically constant, stateless, or free of external side effects.
Code Navigation
gopls is configured for .go files. Use LSP tools rather than Grep or Glob for anything that is a Go identifier —
they resolve imports, aliases, embedded types, and interface satisfaction, which text search cannot.
- Where a symbol is defined —
goToDefinition - Every use of a symbol —
findReferences - Type, signature, or doc of a symbol —
hover - Symbols in one file —
documentSymbol; across the module —workspaceSymbol - Types satisfying an interface —
goToImplementation - Call graph in either direction —
incomingCalls,outgoingCalls
Grep and Glob stay correct for comments, string literals, log messages, build tags, config values, and file-name patterns. Subagents exploring Go code reach the same LSP server — instruct them to use it.
Toolchain
golangci-lint runmust pass before a commit, andgolangci-lint fmtformats — never rungofmtorgoimportsseparately.go fixafter raising thegodirective. It applies the modernizers mechanically; review the diff as a normal change.tooldirectives ingo.modtrack executable dependencies. Add withgo get -tool, run withgo tool <name>. Never reintroduce atools.goblank-import file.
Application
When writing Go, apply these conventions silently — do not narrate a rule while following it. Where existing code contradicts one, follow the codebase and flag the divergence once.
When reviewing Go, cite the violation and show the fix inline. Do not lecture.
Bad: "According to Go conventions, error strings should be lowercase..."
Good: errors.New("Not found.") -> errors.New("not found")
Integration
The coding skill governs workflow — discovery, planning, verification. This skill governs Go implementation choices, and wins on any question of how Go code should read. Both are active at once.
The templ, zog, and charm-tui skills own their library stacks and defer to this skill for language conventions.
When in doubt, write boring code.