Idiomatic Go
The conventions here come from Effective Go and Go Code Review Comments — the documents that codify what Go reviewers reject pull requests over. The content is filtered to the rules that strong models still slip on, so basic syntax, gofmt usage, and well-known mechanics (LIFO defer, new vs make, channel basics) are not repeated here.
The guiding principle is clear is better than clever (Rob Pike's phrasing, elaborated in Dave Cheney's article of the same name). When choosing between a concise/clever construction and an explicit/plain one, pick the explicit one. Code is decoded, not skimmed, and it outlives the person who wrote it.
APIs that require Go 1.22 or later are called out inline below. Older stable symbols (errors.Is, fmt.Errorf with %w, slices.Clone, and similar) are not annotated.
Reference files
Load these as needed — don't read them upfront. This file has the rules and decision points; the references hold the long tail.
references/naming.md— the full initialism table with the awkward cases (OAuth,IPv4,gRPC,IDs,HTTPSProxy), worked naming examples, deeper package-naming guidance (whyutil/common/misccollect garbage and how to split them), import grouping, and file naming with build constraints. Read this when naming anything exported or organizing a new package.references/interfaces-and-errors.md— interface placement worked end-to-end, embedding, compile-time satisfaction checks; sentinel errors vs custom error types vsfmt.Errorf; theerrors.Is/As/Joinmodel and when not to wrap; structured errors;panic/recoverdiscipline. Read this when designing an API, writing a non-trivial error type, or wrapping errors across abstraction boundaries.references/concurrency-patterns.md— thecontext.Contextcontract end-to-end, the four ways a goroutine exits, thefor selectskeleton, channel-direction signatures, semaphore and leaky-buffer patterns,sync.Once/WaitGroup/errgroupdecisions, channel-closing rules, idempotent shutdown against concurrent registration (a coordinator's shutdown safe to call twice and race-free against concurrent subscriber registration), and the anti-patterns (sleeping in tests, polling, fire-and-forget goroutines). Read this when starting a goroutine, designing a cancellable operation, or reviewing concurrent code.references/language-engineering.md— patterns specific to Go tools for programming languages (parsers, language servers/LSP, type checkers, validators, DSLs) that go beyond baseline idiomatic Go: nil-safe AST navigation, callback-vs-iterator traversal, optional capability interfaces, non-nil empty sentinels, typed source coordinates and UTF-16 columns, editor-overlay text handles, lazy scope chains, typed/untyped references and once-only resolution with cycle detection, phased incremental builds and the write-to-read lock downgrade, keeping the domain free of the LSP protocol, and completion as a parser capability. Read this when working on a language implementation, a DSL, or a toolkit built on a Go language framework — not for ordinary application code.
Naming
Naming is where Go diverges most visibly from other languages and where models slip most often.
Initialisms keep one case throughout. URL, ID, HTTP, XML, JSON, API, IO, DB, URI — either all uppercase or all lowercase, never mixed. So userID not userId, ServeHTTP not ServeHttp, parseJSON not parseJson, xmlHTTPRequest not xmlHttpRequest. The rule applies even when the initialism appears in the middle of a name. The reference has the edge cases.
MixedCaps, not snake_case. Both exported (MaxRetries) and unexported (maxRetries) names use mixed caps. Unexported constants are not MAX_RETRIES, even if other languages would write them that way.
Package names: short, lowercase, no underscores. The package name prefixes every call site (http.Get, json.Marshal), so it must read well in context. Avoid util, common, helpers, misc, base, shared — they collect unrelated code and never repay the loan. If a function doesn't fit anywhere, find a more specific home or start a narrower package.
Avoid stutter. Package chubby exports File, not ChubbyFile. Callers write chubby.File and the package prefix supplies the namespacing. Same for functions: bytes.NewBuffer, not bytes.NewBytesBuffer.
Variable name length scales with scope. Use i for a tight loop index, r for a Reader in a five-line function, customerID for a field on an exported struct, defaultTimeout for a package-level constant. The further from declaration a name is used, the more descriptive it must be.
Receiver names are short and consistent. One or two letters that reflect the type — c for Client, b for Buffer, srv for Server. Use the same name across every method on a type. Never self, this, me — receivers are just parameters in Go.
No Get prefix on getters. owner.Name() not owner.GetName(). Setters keep their Set prefix to mark the asymmetry: owner.SetName(n).
Single-method interfaces end in -er. Reader, Writer, Stringer, Closer, Formatter. Multi-method interfaces don't force the suffix.
Interfaces
Define interfaces in the consumer package, not the producer's. The package that needs a Fetcher declares the shape it requires. The package that implements fetching exports a concrete *Client and lets consumers wrap it in whatever interface they need. This keeps the producer free to add methods without breaking consumers, and avoids a forest of speculative interfaces in the implementer.
Accept interfaces, return concrete types. Function parameters use interfaces to describe the minimum needed; return values are concrete so callers see every method and the package can add fields without breaking callers.
// Good: minimal interface input, concrete return.
func NewServer(log Logger) *Server
// Bad: hides fields, blocks future methods, forces type assertions.
func NewServer(log Logger) HTTPHandler
Don't pre-create interfaces "for mocking". Mocks belong in the consumer's test package, against the consumer's interface. A speculative interface in the producer is dead weight and a future API hazard.
Small interfaces compose better. Prefer io.Reader to a six-method Source. If you need read+write, embed: io.ReadWriter is just io.Reader plus io.Writer.
See references/interfaces-and-errors.md for type-assertion forms, embedding, and the compile-time satisfaction check var _ io.Reader = (*MyReader)(nil).
Generics
Write functions first; add type parameters when you would duplicate the same logic for different types. Starting with constraint interfaces is usually the wrong path (When To Use Generics).
Prefer interfaces over type parameters when callers only need methods (io.Reader, io.Writer). Do not rewrite func Read(r io.Reader) into func Read[T io.Reader](r T) — harder to read, rarely faster.
Prefer the standard library (slices, maps, cmp) over custom generic utilities; use cmp.Ordered, not golang.org/x/exp/constraints.
Use type parameters for language containers (slice/map/channel ops with no element-specific logic) and shared data structures — not when each type needs a different method body (use interfaces and separate implementations).
Preserve named slice types: constrain slice args as S ~[]E and return S, not []E, when callers may pass a defined slice type (An Introduction To Generics Scale example).
Map keys need comparable in the type parameter list when a generic type uses map[K]V.
Go 1.27 — generic methods: From Go 1.27, a method may declare type parameters (func (t *T) M[P any](...)). Use them for helpers that naturally live on the receiver type instead of polluting the package with func helper[T, P any](t *T, ...). A generic method does not implement a non-generic interface method: func (*R) Read[E any]([]E) does not satisfy io.Reader. For interface satisfaction, keep ordinary methods or package-level generic functions.
Errors
Return errors as values. Never use in-band sentinels like -1, "", or nil to signal failure when the real return type can take those values legitimately. An explicit (T, error) return makes the failure case unmissable.
Error strings are lowercase, no trailing punctuation. Errors get wrapped and chained: failed to open config: open /etc/app.conf: permission denied only reads right if every link in the chain starts lowercase and ends with no period.
// Good.
return fmt.Errorf("open %s: %w", path, err)
// Bad. Capitalized, ends with period — breaks when wrapped.
return fmt.Errorf("Failed to open %s: %v.", path, err)
Wrap with %w once so errors.Is and errors.As work. Use %v only when you intentionally want to flatten — i.e., the inner error is an implementation detail not part of your contract.
Wrapping is not branching. %w only helps a caller who actually calls errors.Is/errors.As — it does nothing by itself. If your own function needs to treat one failure differently from the rest (not-found vs. everything else), check it with errors.Is/errors.As before you return; don't assume the branch happens further up the stack.
Don't _ = err. If you genuinely cannot act on an error, leave a comment explaining why. Silent discard is the default failure mode for os.Setenv, f.Close() on a read-only file, and similar cases.
Indent the error branch, not the happy path. Early return on failure; let success flow down the page.
Don't panic in libraries. Panic is for unrecoverable invariants, and even then prefer returning an error. recover only works inside a deferred function. See references/interfaces-and-errors.md for the rare cases where intra-package panic-as-cross-function-return is idiomatic.
Concurrency
"Don't communicate by sharing memory; share memory by communicating." This is a guiding principle, not a rule. Channels model ownership transfer — a value passes from one goroutine's responsibility to another's. Mutexes model guarded state — many goroutines need to read or write the same data with a single owner. Both are idiomatic; pick by what the data represents.
Every goroutine needs a documented exit path. A goroutine without a planned termination is a leak waiting to happen. Four standard exits: (1) the work is finite and the function returns, (2) <-ctx.Done(), (3) an explicit close-channel signal, (4) a sync.WaitGroup the parent joins on. If you cannot say which one applies, do not start the goroutine.
context.Context is the first parameter of any function that does I/O, may block, or starts goroutines. Never store a Context in a struct — its lifetime is the call, not the object's. The parameter name is conventionally ctx.
// Good.
func (c *Client) Fetch(ctx context.Context, id string) (*Doc, error)
// Bad. Context in struct; lifetime is now ambiguous.
type Client struct { ctx context.Context; /* ... */ }
Prefer synchronous APIs. A function that returns (Result, error) is composable, testable, and trivially wrappable in a goroutine by anyone who wants concurrency. A function that returns <-chan Result decides for the caller, leaks if the caller forgets to drain it, and complicates error reporting. Write the synchronous version first; let the caller add the goroutine — even a "streaming" API should be a thin wrapper over that synchronous core, not the primary shape.
// Good: synchronous core; concurrency is the caller's choice, not baked into the API.
func (c *Client) FetchOne(ctx context.Context) (Item, error)
// A streaming form wraps the core — it doesn't replace it.
func (c *Client) FetchAll(ctx context.Context) <-chan Item {
out := make(chan Item)
go func() {
defer close(out)
for {
item, err := c.FetchOne(ctx)
if err != nil {
return
}
select {
case out <- item:
case <-ctx.Done():
return
}
}
}()
return out
}
Coming from TypeScript: async/await makes every I/O call asynchronous by default, and the type system reflects it — async function getUser(id): Promise<User> is simply how you write "fetch a user"; the language chose the shape, not the author. Porting that method to Go as func (s *Service) GetUser(id string) chan User reproduces the Promise<T> shape in Go syntax, but Go never forced that choice. func (s *Service) GetUser(ctx context.Context, id string) (User, error) is the default; a caller who actually wants concurrency writes go in front of the call.
Unbuffered channels are synchronization; buffered channels are throughput tolerance. make(chan T) blocks the sender until the receiver receives — that is the synchronization. make(chan T, N) lets the sender get ahead by N values; it doesn't "make things faster", it changes semantics. Pick the capacity by what the data flow requires, not by tuning.
See references/concurrency-patterns.md for the for select { case <-ctx.Done(): ... } skeleton, channel-direction signatures, and decision trees for mutex vs channel and sync.Once vs sync.WaitGroup vs errgroup.
Receiver type: value or pointer
Be consistent within a type. If any method of T takes a pointer receiver, every method should. Mixing forces the reader to remember which is which and produces surprising method-set behavior at interface boundaries.
Use pointer receivers when:
- The method mutates the receiver.
- The struct contains a
sync.Mutexor another field that must not be copied. - The struct is large enough that copying matters.
- You want
nilto be a meaningful receiver value.
Use value receivers when:
- The type is a small fixed-size value:
time.Time, a 2-int struct, a primitive alias. - The type is already a reference (
map,slice,chan,func) — those are pointer-shaped headers; wrapping them in another pointer adds indirection without benefit.
When in doubt, use a pointer receiver. The cost is one indirection; the cost of inconsistency is bugs.
Value semantics: Go copies, TypeScript never does
Coming from TypeScript: every object, array, and class instance is a reference — assignment, passing to a function, and storing something in a collection all point at the same underlying data, so there's no copy to reason about. Go structs are value types. Assignment, passing by value, and for _, v := range all copy. Code written on reference-semantics instincts keeps compiling in Go — it just quietly stops mutating anything.
// Looks right if "objects are always shared" — silently wrong in Go.
func deactivateStale(users []User, cutoff time.Time) {
for _, u := range users { // u is a copy of each element
if u.LastSeen.Before(cutoff) {
u.Active = false // mutates the copy; users is untouched
}
}
}
// Correct: index into the slice so the assignment reaches the real element.
func deactivateStale(users []User, cutoff time.Time) {
for i := range users {
if users[i].LastSeen.Before(cutoff) {
users[i].Active = false
}
}
}
The same gap hides in a value-receiver method (func (u User) Deactivate() only ever mutates its own copy, never the caller's) and in a Redux-style "return the changed copy" update — natural coming from immutable-update habits, but in Go nothing changes until the caller replaces its own variable with the result; there's no in-place effect the way mutating a shared object would have. When a type needs a mutation to be visible to whoever holds the original, hold it by pointer: a pointer receiver, []*T instead of []T, or an index-based assignment like the fix above.
Fixing mutation-visibility does not fix slice-exposure, and vice versa — when one field backs both a mutator and an accessor, check both independently. Switching []User to []*User (or indexing into the original) closes the mutation gap above, but any accessor that still returns a subslice of that same field (return s.active[start:end]) is exactly as unsafe as before — re-slicing []*User still hands the caller a live view of the backing array, so appending to the returned page can still corrupt the service's own next append, and every pointer in it is still the service's own *User. The two fixes live in different sections of this document (this one; "Data gotchas" below) because they are genuinely independent problems that happen to collide on the same field whenever a type both mutates and lists its own cache — applying one is not a side effect of applying the other, so re-check every reader of a field right after you change how it's mutated (and vice versa).
Pass values; avoid pointer-itis
Don't reflexively reach for *int, *string, *bool to "save a copy" or to model optionality. Pointer-to-primitive forces nil-checks at every call site, hides intent, and costs more than the value would have.
For optional configuration, use a zero-value-friendly struct, a functional Option pattern, or distinct method names. For truly optional outputs, return (value, ok) or (value, error).
Zero-value design
Design types so the zero value is usable. bytes.Buffer{}, sync.Mutex{}, http.Client{} all work straight from var x T with no construction step. When the zero value works, skip the constructor — NewFoo() returning &Foo{} with no setup adds a name to maintain without value.
Nil slices are usable. var s []T is an append-able, range-able nil slice. Prefer it over s := []T{} unless you need to distinguish "empty but present" from "absent" — the JSON [] vs null distinction is the canonical case where the explicit empty literal matters.
Maps must be made before writing. The zero value of a map is read-only; var m map[string]int; m["x"] = 1 panics. Either initialize at declaration (m := map[string]int{}) or make before first write.
Data gotchas
Slices share backing arrays — until they don't. A slice is a header over an array; two slices into the same array see each other's writes. Then append exceeds capacity and reallocates, and the two slices silently diverge. When passing slices across an API boundary, document whether you retain or copy, and slices.Clone when in doubt.
Coming from TypeScript: a getter that returns this.items is unremarkable — most callers never mutate through it, and JS arrays don't have a backing-array/capacity split to worry about. Go's equivalent, func (s *Store) Items() []Item { return s.items }, hands back the exact backing array: items[0].Field = x mutates the store's private state through it, and a caller's append may or may not corrupt the store's next append depending on spare capacity. Return slices.Clone(s.items) (or an equivalent copy) whenever a field must not leak a live handle to internal state.
Composite literals: use field-name form. &File{fd: fd, name: name} is robust to field reordering; &File{fd, name, nil, 0} breaks the day someone adds a field.
Control flow and structure
Line-of-sight coding. The happy path stays at the leftmost indent so the function reads top-to-bottom as a straight line. Conditional bodies hold only cleanup, return, or error handling — never the main logic. Mat Ryer's term, popularized by Dave Cheney; it is the most useful single framing for Go control flow.
// Good: happy path on the left margin.
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
// ... use f
switch with no condition is the idiomatic if/else if ladder. It makes the selection explicit and forces every branch to terminate.
switch {
case n < 0:
return ErrNegative
case n == 0:
return nil
default:
return process(n)
}
Avoid fallthrough. Go's no-fall-through default is the feature; opting back into the C behavior re-introduces the bug class.
Naked returns only in functions short enough to read at a glance. In longer functions, explicit returns document what's being returned at every exit.
Named result parameters when meaning would be unclear (e.g., func Split(path string) (dir, file string)) or when needed by defer to set a result. Don't name results just to enable naked returns.
Security: crypto/rand over math/rand
For tokens, IDs, keys, session identifiers, or anything an attacker could exploit by predicting: use crypto/rand (Read, Int(rand.Reader, max *big.Int), and from Go 1.24 Text). math/rand and math/rand/v2 (Go 1.22) are for simulation, jitter, sampling, and tests — not security. The default autocomplete is math/rand; flag it on every use that produces a value an attacker would care about.
Before you're done
Whether you wrote the code or are reviewing a diff, the section headings above are the agenda: walk every one, and flag each violation with the idiom it breaks and the concrete fix. Coverage — not the first few slips — is the bar.