Go Defensive Programming
Hardening Go code is not paranoia — it is the discipline of making your boundaries honest. Copy what crosses them, clean up what you opened, model time and randomness honestly, and never let a panic escape a package.
Core Rules
- Copy slices and maps at API boundaries. They are reference types — leaking the backing array leaks mutation.
deferthe cleanup right after the acquire.f, err := os.Open(...); defer f.Close().- Verify interface compliance at compile time:
var _ I = (*T)(nil). - Model time and durations with
time.Timeandtime.Duration, never raw ints. - Inject
now func() time.Timeinstead of callingtime.Now()directly in production code. - Enums start at
iota + 1so the zero value is invalid. crypto/randfor secrets, nevermath/rand.- Panics never cross package boundaries. Convert to errors at the edge.
- Avoid mutable package-level state. Inject dependencies instead.
Boundary Hardening Checklist
When you touch an exported function or method, walk this list in order:
| # | Check |
|---|---|
| 1 | Return errors, don't panic across boundaries |
| 2 | Copy slices/maps you'll retain |
| 3 | Copy slices/maps you'll return if internal state aliases them |
| 4 | defer Close / Unlock / cancel right after the acquire |
| 5 | Compile-time interface satisfaction check |
| 6 | time.Time / time.Duration types, injected clock |
| 7 | Enum zero = invalid (iota + 1) |
| 8 | crypto/rand for any secret material |
Copy at API Boundaries
// Receiving: copy a slice we'll retain
func (d *Driver) SetTrips(trips []Trip) {
d.trips = make([]Trip, len(trips))
copy(d.trips, trips)
}
// Returning: copy a map so callers can't mutate our state
func (s *Stats) Snapshot() map[string]int {
out := make(map[string]int, len(s.counters))
for k, v := range s.counters {
out[k] = v
}
return out
}
Read references/boundary-copying.md when deciding which boundaries actually need copies (and when copying is wasted work).
Defer Cleanup
defer evaluates arguments at the defer statement and runs the call when the surrounding function returns (LIFO order):
f, err := os.Open(name)
if err != nil {
return err
}
defer f.Close()
Place defer immediately after the acquire — the proximity makes pair-correctness reviewable at a glance.
For locks:
mu.Lock()
defer mu.Unlock()
Beware of defer inside loops — accumulated defers run only when the function returns, not when the iteration ends.
Verify Interface Compliance
var _ http.Handler = (*Handler)(nil)
If (*Handler) ever stops satisfying http.Handler, the build fails. The line costs nothing at runtime and gives you a free contract.
Time Modeling
// Bad — what unit is timeout?
type Config struct {
Timeout int
}
// Good
type Config struct {
Timeout time.Duration
}
For wall-clock work, inject the clock so tests can pin time:
type Signer struct {
now func() time.Time
}
func NewSigner() *Signer {
return &Signer{now: time.Now}
}
// In tests:
s := &Signer{now: func() time.Time { return fixedTime }}
Read references/time-and-enums.md for monotonic time, time zones, struct tags, and embedding tradeoffs.
Crypto Random
import "crypto/rand"
// Go 1.24+
func APIKey() string { return rand.Text() }
math/rand and math/rand/v2 are predictable from a seed — never use them for keys, tokens, nonces, or any secret material.
Must Functions
Must* helpers panic on error. They are appropriate only at program initialization, where failure means the program cannot start:
var (
validID = regexp.MustCompile(`^[a-z][a-z0-9-]{0,62}$`)
tmpl = template.Must(template.ParseFiles("index.html"))
)
Don't write MustFoo for runtime call sites — it shifts an error condition into a crash.
Read references/must-and-panic.md for writing custom
Must*, recovering at goroutine boundaries, and distinguishingpanicfromlog.Fatal.
Avoid Mutable Globals
// Bad — testing requires save/restore dance
var DB *sql.DB
// Good — pass the dependency
type Service struct {
db *sql.DB
}
Constants and once-initialized lookup tables are fine. Mutable package-level vars are a code smell.
Anti-Patterns
| Anti-pattern | Why it hurts | Do this instead |
|---|---|---|
| Storing the caller's slice without copying | Mutation aliasing | make + copy |
| Returning the internal map directly | External mutation of state | Return a snapshot |
time.Now() in business logic |
Hostile to tests | Inject now func() time.Time |
var Timeout = 5 read as seconds elsewhere |
Ambiguous unit | time.Duration |
math/rand for keys |
Predictable from seed | crypto/rand |
panic to signal a domain error |
Crashes the caller | Return an error |
defer inside a tight loop |
Defers stack until function return | Wrap loop body in a function |
Verification Checklist
- Slices/maps stored from callers, or returned aliasing internal state, are copied
- Every
Open/Lockhas adefer Close/Unlocknext to it - Compile-time interface checks cover exported implementations
- Durations are
time.Duration, timestamps aretime.Time; clock is injected - Enum zero values are invalid (or explicitly meaningful)
- No secret material derived from
math/rand - No mutable package-level vars; no
panicacross library boundaries
References
- references/boundary-copying.md — when defensive copies pay off vs. wasted allocation
- references/time-and-enums.md — modeling time, durations, enums, struct tags
- references/must-and-panic.md —
Must*helpers, recover at boundaries, panic vslog.Fatal