Go Best Practices, Idioms & Design Patterns
Go best practices, idioms, and design patterns at principal-engineer level.
A Principal-Engineer-Level Deep Dive
Table of Contents
- Philosophy of Go
- Project Structure & Package Design
- Naming Conventions
- Error Handling
- Interfaces & Composition
- Concurrency
- Generics
- Context
- Design Patterns in Go
- Testing
- Performance & Memory
- Common Pitfalls / Anti-Patterns
- Tooling & Linting
- Logging & Observability
- API & Library Design
- Documentation
1. Philosophy of Go
Go was designed around a small set of core beliefs, and internalizing them is more important than memorizing syntax:
- Simplicity over cleverness. Code is read far more than it’s written. Go deliberately omits features (inheritance, operator overloading, exceptions) to keep the language small.
- Composition over inheritance. There is no class hierarchy. Behavior is built by embedding structs and satisfying interfaces.
- Explicit over implicit. Errors are values, returned explicitly. No hidden control flow like exceptions.
- Concurrency as a first-class citizen. Goroutines and channels are baked into the language, not bolted on as a library.
- “A little copying is better than a little dependency.” Prefer small, local duplication over pulling in a large dependency for one function.
- gofmt settles all style debates. There is one canonical formatting; don’t fight it.
Rob Pike’s maxims worth internalizing:
- Clear is better than clever.
- Reflection is never clear.
- Errors are values.
- Don’t just check errors, handle them gracefully.
- The bigger the interface, the weaker the abstraction.
2. Project Structure & Package Design
2.1 Standard Layout (community convention, not official)
myproject/
├── cmd/
│ └── myapp/
│ └── main.go # thin entrypoint, wires dependencies
├── internal/
│ ├── domain/ # core business logic, no external deps
│ ├── service/ # application/use-case layer
│ ├── transport/
│ │ ├── http/
│ │ └── grpc/
│ ├── repository/ # persistence adapters
│ └── config/
├── pkg/ # code safe for external import (use sparingly)
├── api/ # protobuf/openapi specs
├── migrations/
├── scripts/
├── go.mod
├── go.sum
└── Makefile
internal/is enforced by the Go compiler: nothing outside the module (or outside the parent ofinternal) can import it. Use it aggressively — default to internal, promote topkg/only when you truly want external consumers.cmd/should contain almost no logic — just flag parsing, config loading, dependency wiring, and calling intointernal.- Avoid a generic
pkg/utilsorpkg/commongrab-bag. It becomes a dumping ground and creates artificial coupling. Name packages after what they provide, not what they contain.
2.2 Package Naming
// BAD
package utils
package common
package helpers
// GOOD — package name describes what it provides
package validator
package ratelimit
package sqlrepo
- Package names are lowercase, single-word, no underscores, no
mixedCaps. - Avoid stutter:
validator.Validatoris bad; prefervalidator.New()returning avalidator.Validatoris fine but the type name shouldn’t repeat the package unnecessarily — e.g.,http.Client, nothttp.HTTPClient. - The package name is part of the call site:
log.Info()reads better thanlogging.LogInfo().
2.3 Dependency Direction
Follow a rough hexagonal / clean-architecture split even without a full framework:
domain <-- service <-- transport (HTTP/gRPC handlers)
^
|
repository (implements domain interfaces)
- The
domainpackage should define interfaces (type UserRepository interface {...}) but never import concrete infrastructure (database/sql,net/http). - Concrete implementations (
postgres.UserRepository) live inrepository/and satisfy the domain interface — this is the Dependency Inversion Principle applied idiomatically in Go: interfaces are declared by the consumer, not the producer.
2.4 Avoid Import Cycles
Go forbids cyclic imports at compile time. If you find yourself needing package A to import B and B to import A, extract the shared contract into a third package (often the domain/interfaces package) that both depend on.
3. Naming Conventions
| Element | Convention | Example |
|---|---|---|
| Package | short, lowercase, no underscore | net/http, encoding/json |
| Exported identifiers | MixedCaps | type Client struct{} |
| Unexported identifiers | mixedCaps | func parseHeader() |
| Constants | MixedCaps (no SCREAMING_SNAKE) | const MaxRetries = 3 |
| Interfaces (single method) | Method name + -er | io.Reader, io.Writer, fmt.Stringer |
| Acronyms | keep consistent case | URL, ID, HTTP not Url, Id, Http |
| Getters | no Get prefix | user.Name() not user.GetName() |
| Errors (sentinel) | Err prefix | var ErrNotFound = errors.New(...) |
| Error types | Error suffix | type ValidationError struct{} |
- Receiver names should be short (1-2 letters), consistent across all methods of a type, and never
selforthis:
func (c *Client) Do(req *Request) (*Response, error) { ... }
func (c *Client) Close() error { ... }
- Variable scope should dictate name length. Tight loops:
i,j,k,v. Package-level: descriptive, full words. - Avoid repeating the package name in the identifier:
bytes.Buffernotbytes.BytesBuffer;strings.NewReplacernotstrings.NewStringReplacer.
4. Error Handling
4.1 Errors Are Values
func Divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("divide: division by zero")
}
return a / b, nil
}
Always check errors immediately. Never discard with _ unless you have documented why it’s safe.
4.2 Wrapping with Context (%w)
func LoadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("load config %q: %w", path, err)
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse config %q: %w", path, err)
}
return &cfg, nil
}
%wpreserves the error chain so callers can useerrors.Is/errors.As.- Each wrap should add new contextual information, not repeat the same phrase up the stack.
- Don’t capitalize error strings, don’t end with punctuation (
fmt.Errorf("Failed to open.")is wrong style — should be"failed to open: %w").
4.3 Sentinel Errors vs Error Types vs Opaque Errors
// Sentinel — for simple identity checks
var ErrNotFound = errors.New("resource not found")
if errors.Is(err, ErrNotFound) { ... }
// Custom error type — when you need structured data
type ValidationError struct {
Field string
Msg string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed on %s: %s", e.Field, e.Msg)
}
var ve *ValidationError
if errors.As(err, &ve) {
log.Printf("bad field: %s", ve.Field)
}
// Opaque — caller only needs to know success/failure, not the type
func DoSomething() error { ... } // caller just checks err != nil
Prefer the least amount of exposed error surface that still meets the caller’s needs — “opaque errors” is a valid and often preferable default.
4.4 Multi-Errors (Go 1.20+)
err := errors.Join(err1, err2, err3)
if errors.Is(err, ErrTimeout) { ... }
4.5 panic/recover
panicis reserved for programmer errors (nil dereference of an invariant that should never be nil, index out of range from a logic bug) — not for expected failure paths.- Never let a
paniccross an API/library boundary silently. If a library must usepanicinternally (e.g. recursive descent parsers), it shouldrecover()at the exported entry point and convert to anerror. - In servers, use a top-level
recover()middleware so a single goroutine panic doesn’t crash the whole process — but treat every recovered panic as a bug to fix, not a routine control-flow mechanism.
func Parse(input string) (result Node, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("parse: %v", r)
}
}()
return parseInternal(input), nil
}
4.6 Don’t Log AND Return an Error
A very common anti-pattern:
// BAD — duplicate/triple-logged errors as they propagate up
if err != nil {
log.Println(err)
return err
}
Handle an error once — either log-and-swallow at the top of the call stack, or wrap-and-return. Doing both at every layer produces log spam with the same root cause repeated N times.
5. Interfaces & Composition
5.1 Accept Interfaces, Return Structs
// GOOD
func NewUserService(repo UserRepository) *UserService { ... }
// Consumer defines the minimal interface it needs
type UserRepository interface {
FindByID(ctx context.Context, id string) (*User, error)
}
- Interfaces should be small, ideally 1-3 methods.
io.Reader,io.Writer,sort.Interfaceare canonical examples. - Interfaces belong to the consumer’s package, not the producer’s. Don’t pre-declare interfaces in the package that implements them “just in case” — that’s a C#/Java habit that doesn’t fit Go’s structural typing.
- Return concrete types from constructors so callers get full access to exported methods/fields; let them narrow to an interface if/when they need to, e.g., in a test with a mock.
5.2 Structural Typing / Implicit Satisfaction
type Stringer interface {
String() string
}
type Point struct{ X, Y int }
func (p Point) String() string { return fmt.Sprintf("(%d,%d)", p.X, p.Y) }
// Point automatically satisfies Stringer — no "implements" keyword needed
var s Stringer = Point{1, 2}
Compile-time assertion when you want to guarantee a type satisfies an interface (common in implementations):
var _ io.Writer = (*MyWriter)(nil)
5.3 Embedding for Composition
type Base struct {
ID string
}
func (b Base) Describe() string { return "id=" + b.ID }
type User struct {
Base // embedded — promotes Describe() to User
Name string
}
u := User{Base: Base{ID: "1"}, Name: "Ada"}
u.Describe() // works — promoted method
- Embedding is not inheritance: there’s no polymorphism, no
super, and the embedded type’s methods don’t know about the outer type (no virtual dispatch). - Useful for decorating interfaces — e.g., wrapping an
io.Writerwhile embedding it to inherit the rest of the interface if it’s larger:
type countingWriter struct {
io.Writer
n int64
}
func (w *countingWriter) Write(p []byte) (int, error) {
n, err := w.Writer.Write(p)
w.n += int64(n)
return n, err
}
5.4 The Empty Interface & any
Use any (alias for interface{} since Go 1.18) sparingly — it defeats static typing. Prefer generics (see §7) when you need type-agnostic behavior with type safety.
6. Concurrency
6.1 Goroutines
go func() {
// never let this goroutine leak — always have a clear exit path
}()
- Every goroutine you start must have a well-defined way to stop. A goroutine with no termination path is a leak, just like an unclosed file handle.
- Never start a goroutine without knowing who “owns” its lifecycle and how errors propagate out of it.
6.2 Channels
ch := make(chan int) // unbuffered — synchronous handoff
ch := make(chan int, 10) // buffered — up to 10 in flight without blocking sender
- “Don’t communicate by sharing memory; share memory by communicating.”
- Unbuffered channels are a rendezvous point — great for signaling completion.
- The sender should close a channel, never the receiver. Closing a channel you don’t own, or closing twice, panics.
nilchannels block forever on send/receive — useful for disabling aselectcase dynamically:
var timeout <-chan time.Time
if useTimeout {
timeout = time.After(5 * time.Second)
}
select {
case v := <-ch:
...
case <-timeout: // if nil, this case never fires
...
}
6.3 sync Primitives
var mu sync.Mutex
mu.Lock()
defer mu.Unlock()
var once sync.Once
once.Do(func() { initialize() })
var wg sync.WaitGroup
wg.Add(len(tasks))
for _, t := range tasks {
t := t // capture loop var (see pitfalls §12)
go func() {
defer wg.Done()
process(t)
}()
}
wg.Wait()
- Use channels for coordination and flow of data; use
sync.Mutexfor protecting shared state (a cache, a counter). Neither is universally “more idiomatic” — pick based on the shape of the problem. sync.RWMutexwhen reads vastly outnumber writes.sync/atomicfor simple counters instead of a full mutex when contention matters:
var counter atomic.Int64
counter.Add(1)
6.4 errgroup — The Idiomatic Fan-Out/Fan-In
import "golang.org/x/sync/errgroup"
g, ctx := errgroup.WithContext(ctx)
for _, url := range urls {
url := url
g.Go(func() error {
return fetch(ctx, url)
})
}
if err := g.Wait(); err != nil {
return err
}
errgroup cancels the shared context as soon as any goroutine returns an error, and collects the first error — this is the standard replacement for hand-rolled WaitGroup + error channel plumbing.
6.5 Worker Pools
func workerPool(ctx context.Context, jobs <-chan Job, workers int) <-chan Result {
results := make(chan Result)
var wg sync.WaitGroup
wg.Add(workers)
for i := 0; i < workers; i++ {
go func() {
defer wg.Done()
for job := range jobs {
select {
case results <- process(job):
case <-ctx.Done():
return
}
}
}()
}
go func() {
wg.Wait()
close(results)
}()
return results
}
Pattern: bounded concurrency, context-aware cancellation, and a dedicated closer goroutine so the caller can safely range over results.
6.6 Pipeline Pattern
func generate(ctx context.Context, nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums {
select {
case out <- n:
case <-ctx.Done():
return
}
}
}()
return out
}
func square(ctx context.Context, in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
select {
case out <- n * n:
case <-ctx.Done():
return
}
}
}()
return out
}
// usage: for v := range square(ctx, generate(ctx, 1, 2, 3)) { ... }
Each stage: owns its output channel, closes it when done, respects ctx.Done() for cancellation.
6.7 The Race Detector
Always run tests with go test -race in CI. It’s not optional for concurrent code — data races are undefined behavior in Go’s memory model, and the race detector catches the vast majority of real-world cases.
7. Generics
Introduced in Go 1.18. Use them when you’d otherwise duplicate logic across types or rely on interface{} + reflection/type assertions.
7.1 Basic Syntax
func Map[T, U any](s []T, f func(T) U) []U {
result := make([]U, len(s))
for i, v := range s {
result[i] = f(v)
}
return result
}
doubled := Map([]int{1, 2, 3}, func(n int) int { return n * 2 })
7.2 Constraints
type Number interface {
~int | ~int64 | ~float64
}
func Sum[T Number](nums []T) T {
var total T
for _, n := range nums {
total += n
}
return total
}
~intmeans “int, or any named type whose underlying type is int” — important so your generic function also works ontype Age int.- The standard library’s
cmp.Ordered(Go 1.21+) covers most ordering constraints — prefer it over hand-rolling. - Package
slicesandmaps(Go 1.21+ stdlib) already provide genericSort,Contains,Keys,Values, etc. — check stdlib before writing your own.
7.3 When NOT to Use Generics
- Don’t genericize a function that only ever has one concrete use — YAGNI applies.
- If an interface with 1-2 methods solves the problem, prefer that over a type parameter — generics shine for data structures (containers, algorithms over slices/maps) more than for behavior abstraction, which interfaces already handle well.
8. Context
8.1 The Rules
context.Contextshould be the first parameter of a function, namedctx.- Never store a
Contextinside a struct field — pass it explicitly through the call chain (documented exceptions are extremely rare, e.g. somehttp.Request.Context()internal cases). - Never pass
nil— usecontext.TODO()if genuinely undecided,context.Background()at the true root (main, tests, top-level handler). - Use
context.WithValuesparingly — only for request-scoped metadata that transits API boundaries (trace IDs, auth tokens), never for optional parameters or dependency injection.
func FetchUser(ctx context.Context, id string) (*User, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
...
}
8.2 Cancellation Propagation
func longRunningTask(ctx context.Context) error {
for {
select {
case <-ctx.Done():
return ctx.Err() // context.Canceled or context.DeadlineExceeded
default:
// do a chunk of work
}
}
}
8.3 Custom Context Keys
type ctxKey int
const requestIDKey ctxKey = 0
func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, requestIDKey, id)
}
func RequestIDFromContext(ctx context.Context) (string, bool) {
id, ok := ctx.Value(requestIDKey).(string)
return id, ok
}
Use an unexported custom type for the key (never a raw string) to avoid collisions across packages.
9. Design Patterns in Go
Go doesn’t have classes, so classical GoF patterns are re-expressed via interfaces, composition, and first-class functions. Below are the ones that come up constantly in real Go codebases.
9.1 Functional Options
The idiomatic replacement for constructor overloading / builder-with-setters:
type Server struct {
addr string
timeout time.Duration
tls bool
}
type Option func(*Server)
func WithTimeout(d time.Duration) Option {
return func(s *Server) { s.timeout = d }
}
func WithTLS() Option {
return func(s *Server) { s.tls = true }
}
func NewServer(addr string, opts ...Option) *Server {
s := &Server{addr: addr, timeout: 30 * time.Second} // sane defaults
for _, opt := range opts {
opt(s)
}
return s
}
// usage
srv := NewServer(":8080", WithTimeout(5*time.Second), WithTLS())
Benefits: backward-compatible API evolution (adding an option doesn’t break callers), self-documenting call sites, sensible defaults.
9.2 Builder Pattern (when options aren’t enough)
type QueryBuilder struct {
table string
wheres []string
}
func NewQuery(table string) *QueryBuilder {
return &QueryBuilder{table: table}
}
func (q *QueryBuilder) Where(cond string) *QueryBuilder {
q.wheres = append(q.wheres, cond)
return q
}
func (q *QueryBuilder) Build() string {
query := "SELECT * FROM " + q.table
if len(q.wheres) > 0 {
query += " WHERE " + strings.Join(q.wheres, " AND ")
}
return query
}
sql := NewQuery("users").Where("age > 18").Where("active = true").Build()
9.3 Strategy Pattern
type CompressionStrategy interface {
Compress([]byte) ([]byte, error)
}
type GzipStrategy struct{}
func (GzipStrategy) Compress(b []byte) ([]byte, error) { /* ... */ return b, nil }
type ZstdStrategy struct{}
func (ZstdStrategy) Compress(b []byte) ([]byte, error) { /* ... */ return b, nil }
type Archiver struct {
strategy CompressionStrategy
}
func (a *Archiver) Archive(data []byte) ([]byte, error) {
return a.strategy.Compress(data)
}
In Go, a strategy is often just a function value rather than an interface with one method:
type CompressFunc func([]byte) ([]byte, error)
type Archiver struct {
compress CompressFunc
}
9.4 Decorator Pattern
type Handler func(http.ResponseWriter, *http.Request)
func WithLogging(next Handler) Handler {
return func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next(w, r)
log.Printf("%s %s took %v", r.Method, r.URL.Path, time.Since(start))
}
}
func WithAuth(next Handler) Handler {
return func(w http.ResponseWriter, r *http.Request) {
if !isAuthorized(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next(w, r)
}
}
// composition
handler := WithLogging(WithAuth(myHandler))
This is Go’s idiomatic middleware pattern, ubiquitous in HTTP servers (chi, echo, gin all use variants of it).
9.5 Observer Pattern (Pub/Sub via Channels)
type EventBus struct {
mu sync.RWMutex
subs map[string][]chan Event
}
func (b *EventBus) Subscribe(topic string) <-chan Event {
b.mu.Lock()
defer b.mu.Unlock()
ch := make(chan Event, 1)
b.subs[topic] = append(b.subs[topic], ch)
return ch
}
func (b *EventBus) Publish(topic string, evt Event) {
b.mu.RLock()
defer b.mu.RUnlock()
for _, ch := range b.subs[topic] {
select {
case ch <- evt:
default: // drop if subscriber is slow — non-blocking publish
}
}
}
9.6 Singleton (used sparingly — prefer explicit dependency injection)
var (
instance *Config
once sync.Once
)
func GetConfig() *Config {
once.Do(func() {
instance = loadConfig()
})
return instance
}
Singletons make testing harder (hidden global state) — favor passing dependencies explicitly through constructors wherever feasible. Reserve this pattern for things that are genuinely process-wide, like a metrics registry.
9.7 Factory Pattern
type StorageType string
const (
StorageS3 StorageType = "s3"
StorageLocal StorageType = "local"
)
func NewStorage(t StorageType, cfg Config) (Storage, error) {
switch t {
case StorageS3:
return NewS3Storage(cfg)
case StorageLocal:
return NewLocalStorage(cfg)
default:
return nil, fmt.Errorf("unknown storage type: %s", t)
}
}
9.8 Adapter Pattern
// third-party lib has its own logger interface, incompatible with ours
type thirdPartyLogger interface {
LogMessage(level, msg string)
}
type LoggerAdapter struct {
logger *slog.Logger
}
func (a *LoggerAdapter) LogMessage(level, msg string) {
a.logger.Log(context.Background(), slog.Level(0), msg, "level", level)
}
9.9 State Pattern (via interfaces + type switch, or explicit state machine)
type OrderState interface {
Next(o *Order) OrderState
Name() string
}
type Pending struct{}
func (Pending) Name() string { return "pending" }
func (Pending) Next(o *Order) OrderState { return Shipped{} }
type Shipped struct{}
func (Shipped) Name() string { return "shipped" }
func (Shipped) Next(o *Order) OrderState { return Delivered{} }
9.10 Pipeline / Chain of Responsibility
Already shown in §6.6 — Go’s channel-based pipelines are the idiomatic chain-of-responsibility for streaming data. For synchronous middleware-style chains, see the Decorator pattern (§9.4).
9.11 Null Object Pattern
type NoopMetrics struct{}
func (NoopMetrics) Inc(name string) {}
func (NoopMetrics) Observe(name string, v float64) {}
// used as default so callers never need a nil-check
var DefaultMetrics Metrics = NoopMetrics{}
10. Testing
10.1 Table-Driven Tests
The single most important Go testing idiom:
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
want int
}{
{"positive", 2, 3, 5},
{"negative", -1, -1, -2},
{"zero", 0, 0, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Add(tt.a, tt.b)
if got != tt.want {
t.Errorf("Add(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want)
}
})
}
}
10.2 Subtests & Parallelism
func TestSlow(t *testing.T) {
t.Parallel()
// ...
}
Use t.Parallel() for independent tests to speed up CI, but be careful with shared mutable fixtures (loop variable capture applies to tt in table tests too — Go 1.22+ fixed the classic loop-var bug, but for pre-1.22 code, always shadow: tt := tt).
10.3 Test Doubles via Interfaces
type EmailSender interface {
Send(to, subject, body string) error
}
type mockSender struct {
sent []string
}
func (m *mockSender) Send(to, subject, body string) error {
m.sent = append(m.sent, to)
return nil
}
func TestNotifyUser(t *testing.T) {
mock := &mockSender{}
svc := NewNotificationService(mock)
svc.Notify("a@example.com")
if len(mock.sent) != 1 {
t.Fatalf("expected 1 email sent, got %d", len(mock.sent))
}
}
Small consumer-defined interfaces (§5.1) are precisely what make this trivial — no mocking framework required for most cases, though gomock/mockery/testify/mock help for larger surfaces.
10.4 golden Files
func TestRender(t *testing.T) {
got := Render(input)
golden := filepath.Join("testdata", "render.golden")
if *update {
os.WriteFile(golden, got, 0644)
}
want, _ := os.ReadFile(golden)
if !bytes.Equal(got, want) {
t.Errorf("mismatch, run with -update to regenerate")
}
}
10.5 Fuzz Testing (Go 1.18+)
func FuzzParse(f *testing.F) {
f.Add("valid input")
f.Fuzz(func(t *testing.T, s string) {
_, err := Parse(s)
if err != nil {
return // ok, invalid input
}
// if it parses, re-marshaling should round-trip
})
}
10.6 Benchmark Tests
func BenchmarkConcat(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = strings.Join([]string{"a", "b", "c"}, "")
}
}
Run with go test -bench=. -benchmem to see allocations per op — critical for hot-path optimization decisions.
10.7 Test Organization
- Keep tests in the same package (
package foo) for white-box testing of internals, orpackage foo_testfor pure black-box testing of the public API — many mature projects usefoo_testdeliberately to catch accidental reliance on unexported details. testdata/directory is ignored by the go tool — put fixtures there.- Use
t.Helper()in test helper functions so failure line numbers point to the caller. - Use
t.Cleanup()instead of manual defer-based teardown for composability.
11. Performance & Memory
11.1 Preallocate Slices When Size Is Known
// BAD — repeated reallocation/copy as the slice grows
var result []int
for _, v := range input {
result = append(result, transform(v))
}
// GOOD
result := make([]int, 0, len(input))
for _, v := range input {
result = append(result, transform(v))
}
11.2 Avoid Unnecessary Allocations
- Passing large structs by value copies them — pass pointers for structs beyond a few words, but pass small structs (like
time.Time-sized) by value where semantics call for immutability. - String concatenation in a loop: use
strings.Builder, not+=.
var b strings.Builder
b.Grow(estimatedSize)
for _, s := range parts {
b.WriteString(s)
}
result := b.String()
11.3 Understand Escape Analysis
// n escapes to heap because a pointer to it is returned
func newInt(v int) *int {
n := v
return &n
}
Run go build -gcflags="-m" to see escape analysis decisions. Not everything needs manual tuning, but for hot paths, understanding what forces heap allocation (returning pointers, storing in interfaces, closures capturing by reference) matters.
11.4 sync.Pool for Reusable Buffers
var bufPool = sync.Pool{
New: func() any { return new(bytes.Buffer) },
}
func process() {
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset()
defer bufPool.Put(buf)
// use buf
}
Only worth it for objects that are expensive to allocate and used in hot, high-throughput paths (e.g., HTTP handlers under heavy load). Profile first.
11.5 Profiling
import _ "net/http/pprof"
// then: go tool pprof http://localhost:6060/debug/pprof/profile
pproffor CPU/memory/goroutine/block/mutex profiling.go test -cpuprofile=cpu.out -memprofile=mem.out- Always measure before optimizing. Go’s compiler and runtime are good; premature micro-optimization often hurts readability for no measurable gain.
11.6 Struct Field Ordering (Alignment Padding)
// BAD — 24 bytes due to padding
type Bad struct {
A bool // 1 byte + 7 padding
B int64 // 8 bytes
C bool // 1 byte + 7 padding
}
// GOOD — 16 bytes, fields ordered largest to smallest
type Good struct {
B int64
A bool
C bool
}
Use go vet or fieldalignment (from golang.org/x/tools/go/analysis/passes/fieldalignment) to catch this automatically in large structs.
12. Common Pitfalls / Anti-Patterns
12.1 Loop Variable Capture (fixed in Go 1.22, still relevant for older code / reading legacy code)
// BAD (Go < 1.22) — all goroutines may print the same final value
for _, v := range items {
go func() { fmt.Println(v) }()
}
// FIX (pre-1.22)
for _, v := range items {
v := v
go func() { fmt.Println(v) }()
}
// or pass as parameter
for _, v := range items {
go func(v int) { fmt.Println(v) }(v)
}
Go 1.22+ changed loop semantics so each iteration gets its own variable — but you must still know this when reading pre-1.22 code or setting go.mod compatibility.
12.2 nil Interface vs nil Pointer
type MyError struct{}
func (e *MyError) Error() string { return "boom" }
func doWork() error {
var e *MyError = nil
if false {
e = &MyError{}
}
return e // returns a NON-nil interface wrapping a nil pointer!
}
err := doWork()
fmt.Println(err == nil) // false! classic gotcha
Fix: return nil explicitly, don’t return a typed nil pointer through an interface-typed return value unless intentional.
12.3 Shadowing Errors with :=
func do() error {
x, err := step1()
if err != nil {
return err
}
if x > 0 {
y, err := step2() // shadows outer err inside this block!
if err != nil {
return err
}
_ = y
}
return err // BUG: this outer err may be stale/nil, masking real intent
}
12.4 Ignoring defer in Loops
// BAD — file handles accumulate, closed only when function returns
for _, path := range paths {
f, _ := os.Open(path)
defer f.Close() // all deferred to end of enclosing function, not loop iteration
process(f)
}
// GOOD — wrap in a function so defer fires each iteration
for _, path := range paths {
func() {
f, _ := os.Open(path)
defer f.Close()
process(f)
}()
}
12.5 Misusing Goroutines Without Synchronization
// BAD — main may exit before goroutine runs; also a data race on counter
counter := 0
go func() { counter++ }()
fmt.Println(counter)
12.6 Comparing Structs with Uncomparable Fields
type S struct {
M map[string]int // maps aren't comparable
}
// s1 == s2 // compile error
Use reflect.DeepEqual or a manual Equal method for such types.
12.7 Slice Aliasing / Append Surprises
a := []int{1, 2, 3, 4, 5}
b := a[1:3] // shares backing array with a
b = append(b, 99) // may overwrite a[3] if capacity allows!
Use a three-index slice a[1:3:3] to limit capacity and force a new allocation on append, or copy() when you need true isolation.
12.8 Interface Pollution
Don’t define an interface until you have (or clearly anticipate) more than one implementation, or you need it for mocking in tests. A package that exports only one implementation of every interface is over-abstracted — this is a common carryover from Java/C# habits that doesn’t serve Go well.
12.9 Returning Naked/Unwrapped Errors from Deep Call Stacks
Without wrapping, callers three layers up have no idea where an error like "connection refused" originated. Always add context at each layer boundary (see §4.2).
12.10 Overusing init()
init() functions run implicitly at import time in unpredictable relative order (well-defined within a package, less obvious across packages), make testing harder, and hide control flow. Prefer explicit initialization functions called from main.
13. Tooling & Linting
| Tool | Purpose |
|---|---|
gofmt / goimports | Canonical formatting + import grouping (run on save) |
go vet | Catches suspicious constructs (printf format mismatches, struct tag typos) |
staticcheck | The de-facto standard linter; catches bugs go vet misses |
golangci-lint | Meta-linter aggregating staticcheck, errcheck, govet, revive, etc. |
errcheck | Flags unchecked error return values |
go test -race | Data race detector — mandatory in CI for concurrent code |
go test -cover | Coverage reporting |
govulncheck | Scans dependencies for known CVEs |
go mod tidy | Keeps go.mod/go.sum accurate |
Recommended golangci-lint baseline config includes at least: govet, errcheck, staticcheck, unused, ineffassign, gosimple, bodyclose, noctx, gosec.
# .golangci.yml (minimal example)
linters:
enable:
- errcheck
- gosimple
- govet
- ineffassign
- staticcheck
- unused
- bodyclose
- noctx
- gosec
14. Logging & Observability
14.1 Structured Logging with log/slog (stdlib, Go 1.21+)
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("request handled",
"method", r.Method,
"path", r.URL.Path,
"duration_ms", elapsed.Milliseconds(),
"status", status,
)
- Prefer structured (key-value) logs over
fmt.Sprintf-style strings — machine-parseable, filterable in log aggregators. - Attach a request-scoped logger (with trace ID, user ID, etc.) via context, not globals.
14.2 Metrics & Tracing
expvar(stdlib) for basic process metrics; Prometheus client library (prometheus/client_golang) is the de-facto standard for metrics.- OpenTelemetry (
go.opentelemetry.io/otel) for distributed tracing — instrument at service boundaries (HTTP handlers, DB calls, outbound RPCs).
15. API & Library Design
- Keep the exported surface minimal. Anything exported is a promise to callers; unexport everything not needed externally.
- Semantic versioning matters for modules. A breaking change requires a new major version path (
github.com/you/pkg/v2). - Avoid exposing third-party types in your public API where possible — it locks consumers to your dependency choices and versions.
- Provide context-aware and non-context-aware variants sparingly — usually just require
context.Context; don’t maintain two parallel APIs. - Document exported identifiers with a doc comment starting with the identifier’s name (enforced idiom, checked by
golint/revive):
// Client manages a connection to the remote service and is safe
// for concurrent use by multiple goroutines.
type Client struct { ... }
// Do sends req and returns the parsed response. It respects
// ctx cancellation and deadlines.
func (c *Client) Do(ctx context.Context, req *Request) (*Response, error) { ... }
16. Documentation
- Every exported package should have a package-level doc comment, conventionally in a
doc.gofile for larger packages:
// Package ratelimit provides token-bucket rate limiters safe for
// concurrent use across multiple goroutines.
package ratelimit
- Use runnable Example functions — they’re compiled, optionally executed as tests, and shown directly in godoc:
func ExampleClient_Do() {
c := NewClient()
resp, _ := c.Do(context.Background(), req)
fmt.Println(resp.Status)
// Output: 200 OK
}
- Keep README focused on: what the project does, install instructions, a minimal usage example, and a link to full godoc — not a duplicate of API reference docs.
Closing Checklist for Code Review
- Errors wrapped with context, checked immediately, handled once
- No goroutine without a clear termination/ownership story
- Interfaces small and consumer-defined
-
context.Contextthreaded properly, never stored in structs - No loop-variable capture bugs (or confirmed Go 1.22+)
-
go vet,staticcheck,-raceall clean - Exported identifiers documented
- No premature interface/generic abstraction
- Table-driven tests for non-trivial logic
- Struct fields ordered to minimize padding (hot structs only)