// goroutines · channels · interfaces · GC · concurrency patterns · senior → principal
GOMAXPROCS OS threads, defaults to CPU count). The scheduler is cooperative-preemptive — goroutines yield at function calls, channel ops, and since Go 1.14, at safe points during long loops. go f() costs ~1 µs; spawning millions is practical.
close(ch) signals no more values; range ch drains and exits on close.
implements keyword. Any type with the required method set satisfies the interface. This enables duck typing with compile-time safety. The empty interface (any / interface{}) accepts all types. Composition is the Go idiom: embed small interfaces (io.Reader, io.Writer) and compose larger ones. Avoid large interfaces — they couple callers to implementations.
GOGC, default 100 — GC runs when heap doubles since last collection). Escape analysis decides whether a variable lives on the stack (fast, no GC) or heap (GC managed). Values that outlive their function or are too large escape to heap. Check with go build -gcflags="-m".
error interface. Functions return (result, error) — callers must check. Sentinel errors: var ErrNotFound = errors.New(...) for equality checks. Error wrapping (fmt.Errorf("doing X: %w", err)) preserves the chain. errors.Is walks the chain for equality; errors.As walks it for type assertion. panic is for unrecoverable programmer errors (nil dereference, index out of bounds) — not a control-flow mechanism.
context.Context carries deadlines, cancellation signals, and request-scoped values across API boundaries. Always accept ctx context.Context as the first parameter of any function doing I/O or waiting. context.WithCancel returns a cancel function you must call (defer it). context.WithTimeout and context.WithDeadline auto-cancel. A cancelled context propagates through the whole call chain — the correct way to stop a goroutine tree without goroutine leaks.
runtime.NumGoroutine() or pprof's goroutine profile. Fix by passing context and checking ctx.Done() in every long-running goroutine.
var e *MyError = nil; var err error = e; err != nil evaluates to true. Return bare nil from functions returning error, never a typed nil pointer. This trips up almost every Go developer at some point.
i := i) or pass it as a function argument. In Go 1.22+, each iteration creates a new variable — this gotcha is eliminated for range loops.
make + copy. Be explicit about whether a function takes ownership of a slice or just reads it.
concurrent map read and map write). Protect with sync.RWMutex or use sync.Map (optimized for high-read, low-write and stable key sets). The race detector (-race) catches this at runtime — always run tests with it in CI.
defer runs at function return, not at the end of a loop iteration. Deferring a file.Close() inside a loop that opens many files will exhaust file descriptors before any are closed. Fix: extract the body into a function and defer inside it, or close explicitly without defer in the loop.
| goroutine | go f() — ~2 KB initial stack, grows to GB, managed by runtime scheduler. |
| chan T | make(chan T) unbuffered; make(chan T, n) buffered. Send: ch<-v. Recv: v:=<-ch. |
| select | Non-deterministically picks a ready channel case. Default case = non-blocking. |
| defer | Executes at function return, in LIFO order. Args evaluated at defer site. |
| interface | Implicit satisfaction. nil interface ≠ typed nil pointer. Prefer small interfaces. |
| struct embedding | Not inheritance — promotion of methods. Compose behavior without subclassing. |
| slice | make([]T, len, cap). Three-word header: ptr, len, cap. Shares backing array. |
| map | make(map[K]V). Not concurrent-safe. Zero value is nil (panic on write). Init before use. |
| sync.Mutex | Mutual exclusion. Lock() / Unlock(). Use defer mu.Unlock() after Lock(). |
| sync.RWMutex | Multiple concurrent readers, one writer. RLock/RUnlock for reads. |
| sync.WaitGroup | Add(n) before spawning, Done() in each goroutine, Wait() to block until all done. |
| sync.Once | Executes a function exactly once across goroutines. Lazy init pattern. |
| sync.Map | Concurrent map optimized for stable keys with many reads. Prefer Mutex+map for writes. |
| sync.Pool | Pool of reusable objects to reduce GC pressure. Not a cache — items may be collected. |
| atomic | sync/atomic for lock-free primitives (Add, Load, Store, CompareAndSwap) on int/ptr. |
| GOGC | Target heap growth ratio before GC triggers. Default 100 (100% = double). Lower = less memory, more CPU. GOGC=off disables. |
| GOMEMLIMIT | Soft memory cap (Go 1.19+). GC aggressively reclaims when near limit. Set to ~90% of container limit. |
| GOMAXPROCS | Number of OS threads for goroutines. Defaults to CPU count. Set equal to vCPU for most workloads. |
| go build -gcflags=-m | Print escape analysis decisions. Variables that escape to heap increase GC pressure. |
| pprof | CPU, heap, goroutine, mutex, block profiles. import _ net/http/pprof for /debug/pprof endpoint. |
| go mod init module/path | Initialize a new module. Creates go.mod. |
| go get pkg@version | Add or upgrade a dependency. Use @latest or @v1.2.3. |
| go mod tidy | Add missing and remove unused module requirements. |
| go mod vendor | Copy dependencies into vendor/ for offline or reproducible builds. |
| go mod graph | Print the module dependency graph. |
| go list -m all | List all modules in the build (direct and transitive). |
| Dimension | Go | Java (modern) | Rust |
|---|---|---|---|
| Concurrency model | Goroutines + channels (CSP); M:N scheduler | Virtual threads (JDK 21+) or reactive streams | Async/await or OS threads; no data races by design |
| Memory management | Concurrent GC; ~1 ms STW pauses | G1/ZGC; sub-ms pauses possible | No GC; ownership + borrow checker; zero runtime overhead |
| Startup time | ~10 ms; small static binary | ~100 ms–1 s (JVM warmup) | ~1 ms; single binary |
| Binary size | ~5–15 MB (statically linked) | Fat JAR + JVM | ~1–5 MB |
| Error handling | Explicit (error return values) | Exceptions (checked/unchecked) | Result |
| Generics | Since 1.18; good for collections/algorithms; no specialization | Mature generics with type erasure | Full monomorphized generics |
| Learning curve | Low; ~1 week for basics | Medium; rich ecosystem to learn | High; borrow checker requires mental shift |
| Best for | Network services, CLIs, infrastructure | Enterprise, large teams, JVM ecosystem | Systems programming, safety-critical, zero-overhead FFI |
GOMAXPROCS below CPU count can help in environments that charge by CPU (e.g., you have 2 vCPUs allocated in a 64-core host). Setting it above physical cores is rarely useful.GOMAXPROCS to 64 — causing massive context-switching and throttling. Fix with GOMAXPROCS=2 explicitly or the go.uber.org/automaxprocs library, which reads cgroup quotas at startup. This is a frequent and impactful production footgun.make(chan T)): sender blocks until a receiver is ready, and vice versa. Acts as a synchronization point — both sides rendezvous. Use when you want to guarantee the receiver has started processing before the sender continues.
Buffered channel (make(chan T, n)): sender blocks only when the buffer is full; receiver blocks only when it's empty. Decouples sender and receiver pace up to the buffer capacity.
Common patterns: - Unbuffered for signaling/synchronization (done channels, semaphores with 1-capacity) - Buffered for work queues where you want to absorb bursts - A channel of capacity 1 used with a non-blocking send (select + default) is a simple
"coalesce" — only signal if nobody is already notifiedAdd called incorrectly, Wait blocks forever - Mutex deadlock: two goroutines each hold a lock the other needs
Detection: runtime.NumGoroutine() trend over time; pprof goroutine profile (/debug/pprof/goroutine) shows stack traces of all live goroutines — leaked ones appear blocked at the same site repeatedly. The goleak package (test helper) fails tests if goroutines remain after the test.
Fix pattern: every long-running goroutine should select on ctx.Done() and exit cleanly. Design goroutine lifetimes explicitly — who creates it, who cancels it, how does it signal done.errgroup.Group (golang.org/x/sync/errgroup) so the group's context cancels all members on first error. This makes goroutine lifetime scoped to a logical unit of work rather than spread across the codebase.func (s *S) Method()): the method receives a pointer to the struct, so it can mutate the value and its changes are visible to the caller. Required when the method needs to modify the receiver, or when the struct is large (avoids copying).
Value receiver (func (s S) Method()): gets a copy of the struct. Changes don't affect the original. Suitable for small, read-only methods and primitive-like types.
Rules of thumb: - If any method on a type uses a pointer receiver, use pointer receivers for all methods
(consistency for the method set; a *T value satisfies both pointer and value receiver
interfaces, but a T value only satisfies value receiver interfaces)
- Mutating methods must use pointer receivers - Large structs should use pointer receivers to avoid copying overhead - Small immutable structs (like time.Time) can use value receivers*T has a method defined with a pointer receiver, you cannot pass a plain T value where the interface is expected — only *T satisfies it. This means if you ever want to satisfy an interface with your type, decide early whether it'll be T or *T that implements it, and stay consistent. In practice, use pointer receivers as the default for non-trivial structs.defer pushes a function call onto a stack that executes LIFO when the surrounding function returns (including via panic).
Argument evaluation: arguments to the deferred function are evaluated at the defer statement, not at execution. defer fmt.Println(x) captures x's current value. To defer with the latest value, use a closure: defer func() { fmt.Println(x) }().
Named returns: a deferred function can read and modify named return values. go func double(n int) (result int) {
defer func() { result *= 2 }()
result = n
return // returns n*2, not n
} This is powerful for cleanup that depends on whether an error occurred.
Common patterns: defer mu.Unlock(), defer file.Close(), defer span.End(). Defer in a loop is a bug — it accumulates deferred calls until function return._defer record on the heap (or stack in modern Go with stack-allocated defers). In hot paths called millions of times per second, explicit Close() calls can be meaningfully faster. Profile before optimizing; for most code the clarity of defer outweighs the cost.fmt.Errorf("... %w", err): wraps err inside a new error, preserving the chain. The original error is accessible via errors.Unwrap.
errors.Is(err, target): walks the error chain and returns true if any error in the chain equals target. Use for sentinel error comparison: errors.Is(err, sql.ErrNoRows).
errors.As(err, &target): walks the chain looking for an error assignable to target's type. Use to extract a typed error: var ne *net.Error; errors.As(err, &ne) then read ne.Timeout().
Sentinel errors (var ErrNotFound = errors.New("not found")): for equality checks. Don't use == for error comparison when wrapping is involved — always use errors.Is.
Typed errors (structs implementing error): when callers need structured data from the error (e.g., HTTP status code, retry-after duration).fmt.Errorf. Adding context to errors (fmt.Errorf("parsing config: %w", err)) creates a traceable chain from API boundary down to root cause — equivalent to a stack trace in prose form. Avoid stripping the error chain (errors.New(err.Error())) — it loses the chain and makes debugging production issues significantly harder.context.Context is the idiomatic way to carry deadlines, cancellation, and request-scoped values across goroutines and API boundaries.
Rules: - Always accept ctx context.Context as the first parameter of functions doing I/O or waiting - Never store context in a struct field — pass it explicitly - Always call the cancel function returned by WithCancel/WithTimeout (use defer cancel()) - Check ctx.Err() or ctx.Done() in any blocking loop - Use context.WithValue sparingly — only for request-scoped data (trace IDs, auth tokens),
not for optional function parameters
Common misuses: - Ignoring the cancel function → context and its resources leak - Storing context in a struct and using it across requests → wrong context for the request - Passing context.Background() instead of the request context → deadline/cancellation lost - Using context.WithValue with string keys → collisions; use unexported type keys
ctx correctly, adding distributed tracing later is a near-zero-code-change. If you bypassed context propagation early, retrofitting it means touching every layer. Treat context propagation as infrastructure, not application code.GOGC% above the live heap size after the last collection (default: 100 — heap doubles before GC runs).
Key knobs: - GOGC: lower → GC runs more often → less memory, more CPU. GOGC=off disables. - GOMEMLIMIT (Go 1.19+): a soft ceiling; GC works harder as you approach it. Set to ~90%
of container memory limit to prevent OOM kills.
- runtime/debug.SetGCPercent() and SetMemoryLimit() at runtime for dynamic tuning.
Diagnostics: - GODEBUG=gctrace=1: prints a line per GC cycle with pause times, heap sizes - pprof heap profile: see what's allocated and by whom - go tool trace: fine-grained execution trace with GC events - Look for scvg events (OS memory release) and large heap retention
Reducing GC pressure: reuse allocations with sync.Pool, prefer value types over pointers (stack allocation), avoid interface boxing of small values in hot paths.GOMEMLIMIT=460MiB and GOGC=off delegates control entirely to the memory limit, reducing CPU waste and eliminating the "GC death spiral" pattern where GC overhead exceeds useful work under load. Benchmark the tradeoff for your workload's allocation rate and latency profile.for job := range jobs, processing one at a time. Use when downstream resources are limited or you need to bound memory/CPU.
Fan-out: distribute work across N goroutines, each doing independent processing. errgroup.Group is the idiomatic wrapper — starts goroutines, collects first error, cancels all via context on any error. Use for concurrent independent I/O (N API calls that don't depend on each other).
Fan-in: merge multiple channels into one. A goroutine per input channel forwards values to a merged output channel; a WaitGroup closes the output when all inputs are done. Use when N producers feed one consumer.
Pipeline: stages connected by channels where each stage transforms data. Each stage reads from an upstream channel and writes to a downstream channel. Cancellation propagates upstream by closing channels. Use for data transformation sequences where stages have different processing rates.golang.org/x/sync/errgroup with a shared context is almost always the right primitive over raw goroutines + WaitGroup.io.Reader or a custom UserStore interface instead of concrete types can be tested with fake implementations without a real database or network.
Constructor injection: pass dependencies into struct constructors. Avoid init() and package-level state — they make tests order-dependent and hard to parallelize.
Table-driven tests: Go idiomatic. Define a slice of {name, input, want} structs and range over them, calling t.Run(tc.name, ...) for subtests. Run with -v to see each case.
Mocking: generate mocks with mockgen (gomock) or use hand-written fakes that satisfy the interface. Prefer fakes for complex behaviors, mocks for simple call assertions.
httptest: httptest.NewRecorder() and httptest.NewServer() for testing HTTP handlers and clients without a real server.
t.Helper(): call in assertion helpers so failures report the caller's line, not the helper.storage package exporting a Repository interface for its users, not for itself. Small, focused interfaces are easier to satisfy in tests and easier to change. If your mock needs 20 methods, your interface is probably too large. When teams struggle with testing, the root cause is almost always dependency injection not being applied at the design phase — retrofitting it later is expensive.sync.Map is optimized for two specific access patterns: 1. A given key is written once and read many times (stable key set) 2. Multiple goroutines read/write disjoint key sets
It uses an internal read-only snapshot + dirty map with a mutex only for dirty reads/writes. API is more verbose (Load, Store, Delete, Range) and not generic (pre-generics stores any).
sync.RWMutex + map: more flexible, type-safe (with generics: map[K]V protected by mutex), simpler to reason about. RLock/RUnlock for reads allows multiple concurrent readers. Better when write rate is non-trivial, keys change frequently, or you need atomic read-modify-write operations (Lock → read → modify → write → Unlock as a unit).
Rule of thumb: start with RWMutex + map. Profile before considering sync.Map. The sync.Map optimization only pays off when the map is read-dominated and the key set is stable — benchmarks show it underperforms a plain mutex+map for write-heavy workloads./controllers, /models, /services folders at the top level).
Common structure: cmd/server/ ← main package, wires dependencies, thin internal/ ← private packages; enforced by compiler (external import fails)
domain/ ← pure business logic, no external imports
storage/ ← database adapters implementing domain interfaces
transport/ ← HTTP/gRPC handlers
config/ ← configuration loading
pkg/ ← public library code others can import
Principles: - cmd/ packages are thin wiring — no business logic - internal/ enforces boundaries; prefer it aggressively - Define interfaces in the package that uses them, not the package that implements them - Avoid circular imports — they reveal design problems; don't work around them with hacks - domain/ should have zero external imports — testable without infrastructure
The internal/ directory is Go's only compiler-enforced encapsulation. Use it freely. Don't follow the "flat package" or "one giant package" extremes — find the natural seams in your domain and put them there.domain import storage? That's a red flag (business logic depending on infrastructure, not the other way around). The dependency rule (domain doesn't depend on adapters) makes the service replaceable — swap the DB from Postgres to DynamoDB by writing a new adapter, not by modifying business logic.prometheus/client_golang): instrument at service, not infrastructure level. Request rate, error rate, latency histograms (p50/p95/p99), goroutine count, GC stats. Use promhttp.Handler() for /metrics. Label carefully — high-cardinality labels (per-user-ID) destroy Prometheus performance.
Structured logging (log/slog, Go 1.21+, or zap): JSON output. Every log line should carry trace_id, service, level, and relevant context fields. No fmt.Println in production code. Log at entry/exit of external calls (latency + error) not at every internal step.
Distributed tracing (OpenTelemetry SDK): propagate context.Context carrying spans. Instrument HTTP clients and servers with OTel middleware. Connect to Jaeger/Tempo.
Practical wiring: an observability package initialized at startup injects a *slog.Logger, an OTel TracerProvider, and registers Prometheus metrics. All other packages receive these via dependency injection — no package-level globals.errgroup.Group with the request context. Each call runs in its own goroutine. The group's context is derived from the request context — if the HTTP request is cancelled (client disconnects), all downstream calls are cancelled via context propagation.context.WithTimeout for each downstream call, separate from the request deadline. A slow recommendations service shouldn't eat the entire 200 ms budget — give it 150 ms, fail fast, return a degraded response.Wait() returns. No goroutines outlive the request handler. Verify with a goroutine count check in load tests.http.Client with a tuned transport (MaxIdleConnsPerHost, timeouts). One shared client per downstream, not one per request.github.com/sony/gobreaker or a similar library. Avoids cascading latency when a downstream is degraded.http.Client per request — exhausts file descriptorscontext.Background() instead of the request context — cancellation lostjobs channel. The channel acts as the work queue; its capacity controls bursting. Workers block on the channel when idle — no busy-waiting.jobs channel (signals workers no more work is coming). Workers drain and exit range jobs. A sync.WaitGroup tracks in-flight workers; main goroutine calls wg.Wait() before exiting. Give a deadline (30 s) for drain.RetryCount. On failure, re-enqueue with RetryCount++ and a delay. After 3 failures, write to DLQ topic/table with error context. Don't retry indefinitely — unbounded retry queues are memory leaks.jobs channel is full (all 100 workers busy), the reader goroutine blocks. This is intentional — backpressure signals the queue source to slow consumption. Make the channel capacity small (e.g., 2× worker count) to limit in-memory buffering.server.Shutdown(ctx) with a timeout (e.g., 30 s). Shutdown stops accepting new connections and waits for in-flight requests to complete. Never os.Exit directly — it drops all in-flight requests instantly./ready). Kubernetes won't route traffic until readiness succeeds — pod starts, loads cache, becomes ready, receives traffic.minReadySeconds ensures new pods stabilize before old pods are removed.preStop hook with a 5-second sleep before graceful shutdown begins — this absorbs the propagation lag and prevents a small window of dropped connections.