Control Flow Best Practices
Readable flow-control habits and when to refactor nested logic.
These rules keep Go code flat, predictable, and safe under concurrency - from if err != nil discipline to panic boundaries in servers.
How to Use This List
- Apply during code review when nested
ifchains or loop-heavy handlers land in a PR. - Run
go vet,staticcheck, and golangci-lint to catch suspicious control flow automatically. - When a rule conflicts with benchmark data, document the exception with a comment linking the profile.
- Revisit after Go upgrades; loop variable semantics changed in 1.22 and iterators expanded in 1.23+.
A - Conditionals and early exit
- Handle errors at the call site with init-style
if.if err := f(); err != nil { return err }beats nested success paths. - Return early instead of deep nesting. Guard clauses for invalid input keep the happy path at the left margin.
- Prefer
switchfor multi-value discrimination on one expression. Longelse ifchains on the same variable signal a switch. - Avoid
fallthroughunless you can name the reason in a comment. Shared case bodies should call a helper function instead. - Do not use
panicfor expected request or I/O failures. Returnerrorand let callers map to HTTP/gRPC status.
B - Loops and range
- Default to
for rangefor slices, maps, channels, and strings. Use index loops when you need neighbors or reverse iteration. - Never depend on map iteration order. Sort keys when output must be stable for tests or diffs.
- Mutate slices by index, not range value.
s[i] = xupdates the collection; the range value is a copy. - Pass loop variables into goroutines or defer parameters on older code paths. Defensive copies document intent even on Go 1.22+ libraries.
- Close channels from senders and document who closes.
for rangeon channels deadlocks when nobody closes.
C - break, continue, labels, and defer
- Use
break labelwhen exiting nested search loops. Replacefoundboolean pyramids with a label or helperreturn. - Label
breakout offor { select }loops. Barebreakonly exitsselect, not the loop. - Place
deferimmediately after successful resource acquisition. Readers pairOpen/Lockwithdefer Close/Unlockvisually. - Avoid
deferinside tight loops without a nested function. Defer stacks grow until the outer function returns. - Keep deferred functions tiny and panic-free. Panics during unwind complicate recovery and obscure root cause.
D - HTTP, middleware, and panic boundaries
- Install panic recovery middleware on every production HTTP server. chi
Recoverer, ginRecovery, echoRecover, or hand-writtendefer recover. - Log request ID, path, and stack on recovered panics. Clients see 500; operators need actionable context.
- Short-circuit middleware with
returnafter writing responses. Never callnextafter you have finished the response. - Thread
r.Context()into downstream I/O. Timeout middleware only works when handlers respect cancellation. - Defer
r.Body.Close()(or equivalent) per request. Leaked bodies exhaust connections under load.
FAQs
How deep should if nesting go?
Rarely more than two levels in application code.
Extract functions or use guard clauses when deeper.
When is a labeled break better than a helper function?
When a tight double loop would pay for an extra call and the label names the exit target clearly.
If logic grows past that, extract.
Should I always use defer for mutex unlock?
Yes for function-scoped locks - defer mu.Unlock() after Lock() survives panics.
For hot paths inside loops, hoist locking outward.
Is switch with true still acceptable?
Tagless switch { case cond: is the modern spelling.
Same semantics, clearer intent.
Do I need recover in every handler?
No - one recovery middleware wrapping the tree is enough.
Duplicate layers are redundant, not harmful.
When should I refactor a for loop into a function?
When the body exceeds one screen, needs multiple exits, or repeats in tests.
Named functions improve stack traces and unit testing.
Are goto statements ever OK?
Almost never in application code.
Prefer labels, helpers, or data-driven dispatch tables.
How do I keep map-range deletes safe?
Deleting the current key during iteration is defined.
Keep delete logic simple; extract keys first if unsure.
Should libraries panic on bad input?
Prefer errors for callers who can react.
Reserve panic for documented misuse that should fail in development.
What linters help control flow?
staticcheck, govet, gosimple, and revive flag suspicious constructs.
Run via golangci-lint in CI.
Related
- Go Control Flow: Expression-Oriented Design - section overview
- Control Flow Basics - runnable intro examples
- defer, panic & recover - panic and defer mechanics
- range Variable Reuse Pitfalls - loop capture bugs
- Control Flow in HTTP Middleware - server panic boundaries
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).