Defensive coding
| Field | Value |
|---|---|
| Type | Skill Resource |
| Source | ~/.copilot/skills/backend/references/safety.md |
| Description | Not specified |
Source Content
Defensive coding
These are the bugs that compile cleanly, pass a shallow review, and only surface under production load or a specific input. go vet and golangci-lint (references/lint.md) catch some of them — not all. Know the rest by hand.
append can silently overwrite a shared slice
append reuses the backing array when there is spare capacity. If two slices share that array — one a sub-view of the other — appending to one silently corrupts the other.
// BUG: sub and invoices share a backing arrayinvoices := make([]domain.Invoice, 2, 4) // len=2, cap=4 — two spare slotssub := invoices[:1]sub = append(sub, domain.Invoice{ID: "inv_99"}) // writes into invoices[1], no error, no signalForce a fresh backing array before appending to a slice that might be shared, either with a three-index slice expression that caps capacity at the current length, or with slices.Clone:
sub := invoices[:1:1] // cap == len, so append must reallocatesub = append(sub, domain.Invoice{ID: "inv_99"}) // invoices is untouched
// equivalent:sub := slices.Clone(invoices[:1])Subslicing keeps the whole backing array alive
data[:64] on a 1 MB []byte looks like it copies 64 bytes. It does not — the returned slice still points into the original 1 MB array, so as long as anything holds a reference to the small slice, the entire megabyte stays resident. Caching a parsed header from a large response body is the classic leak:
func parseHeader(raw []byte) Header { // raw is a 1 MB response body; Magic keeps all 1 MB alive for as long as // the returned Header is cached return Header{Magic: raw[:64]}}Clone the small piece before discarding the large buffer:
func parseHeader(raw []byte) Header { return Header{Magic: bytes.Clone(raw[:64])} // 64 bytes retained, 1 MB freed}slices.Clone is the generic equivalent for non-byte slices.
defer in a loop accumulates for the whole function
A defer fires when the enclosing function returns, not when the loop iteration ends. defer f.Close() inside a loop that processes 10,000 files holds all 10,000 open until the function exits, not one at a time.
// BUG: every file stays open until importAll returnsfunc importAll(paths []string) error { for _, p := range paths { f, err := os.Open(p) if err != nil { return fmt.Errorf("open %s: %w", p, err) } defer f.Close() if err := process(f); err != nil { return fmt.Errorf("process %s: %w", p, err) } } return nil}Wrap the loop body in its own function so the defer fires per iteration:
func importAll(paths []string) error { for _, p := range paths { if err := importOne(p); err != nil { return err } } return nil}
func importOne(path string) error { f, err := os.Open(path) if err != nil { return fmt.Errorf("open %s: %w", path, err) } defer f.Close() // fires when importOne returns, not when importAll returns return process(f)}An explicit f.Close() at the end of the loop body works too; the point is that the resource does not outlive its iteration.
The typed-nil-in-interface trap
The single most common silent-error bug in Go. A nil pointer boxed into an error interface is not a nil error — the interface holds a (type, value) pair, and the type is non-nil even though the value is.
type InvoiceError struct{ Code string }
func (e *InvoiceError) Error() string { return "invoice error: " + e.Code }
func validate(inv domain.Invoice) *InvoiceError { if inv.TotalCents < 0 { return &InvoiceError{Code: "negative_total"} } return nil}
func (s *InvoiceService) Create(ctx context.Context, inv domain.Invoice) error { verr := validate(inv) return verr // BUG: returns a non-nil error interface even when verr == *InvoiceError(nil)}Every caller’s if err != nil now takes the error branch, forever, even on success. Check the concrete pointer at the point of construction, before it gets boxed into the wider error interface:
func (s *InvoiceService) Create(ctx context.Context, inv domain.Invoice) error { if verr := validate(inv); verr != nil { return verr } return nil}Concurrent map access is not a race — it is a fatal crash
An unsynchronized concurrent read and write on a plain Go map is not merely undefined behavior caught by -race. It is a fatal, unrecoverable runtime error — fatal error: concurrent map read and map write — that recover() cannot catch, so it takes down the entire process even when it happens inside a goroutine that conc is supervising.
cache := map[domain.InvoiceID]domain.Invoice{}
// goroutine Acache[id] = inv
// goroutine B, running concurrently_ = cache[id] // fatal error: not a panic, recover() does not helpThis is exactly why references/concurrency.md’s “shared state needs a guard” rule exists: guard the map with a sync.Mutex/sync.RWMutex, use sync.Map, or — the preferred fix — don’t share the map at all; give each goroutine its own and combine results at the join.
Narrowing conversions truncate silently
int64 -> int32, int -> int8, and similar narrowing conversions do not error on overflow. They wrap silently. Bounds-check any value that came from outside the process — a request body, a query parameter, a message off a queue — before narrowing it.
// BUG: silently wraps if raw is out of int32 rangefunc parsePage(raw int64) int32 { return int32(raw)}func parsePage(raw int64) (int32, error) { if raw < math.MinInt32 || raw > math.MaxInt32 { return 0, fmt.Errorf("page %d out of int32 range", raw) } return int32(raw), nil}JSON decoding gotchas
Large integers decoded into map[string]any lose precision
encoding/json decodes any JSON number into a map[string]any as float64. Above 2^53, that silently loses precision — a problem for invoice IDs, Snowflake IDs, or any integer near or past that range.
var body map[string]any_ = json.Unmarshal(data, &body)id := body["invoice_id"].(float64) // precision lost above 2^53, no error raisedDecode into a typed struct with an explicit int64 (or json.Number when the type is genuinely variable) instead of a bag of any:
type createInvoiceRequest struct { InvoiceID int64 `json:"invoice_id"`}Unexported fields are silently skipped
encoding/json only sees exported fields. A lowercase typo where an exported field was intended does not error — the field is simply dropped, with no signal at all.
type Invoice struct { ID string total int64 // BUG: unexported — encoding/json skips it, marshal and unmarshal both silently drop it}type Invoice struct { ID string Total int64 `json:"total_cents"`}Never copy a sync type by value
sync.Mutex, sync.WaitGroup, and the other sync types must never be copied after first use. A struct that embeds a sync.Mutex and is passed, assigned, or returned by value duplicates the lock — the copy protects nothing, and the two copies no longer exclude each other.
type Cache struct { mu sync.Mutex data map[string]domain.Invoice}
func (c Cache) Get(id string) domain.Invoice { // value receiver copies mu on every call c.mu.Lock() defer c.mu.Unlock() return c.data[id]}go vet’s copylocks check flags the direct cases — assignment, passing by value, returning by value, and a value-receiver method defined straight on the struct. Use a pointer receiver so the lock is never duplicated:
func (c *Cache) Get(id string) domain.Invoice { c.mu.Lock() defer c.mu.Unlock() return c.data[id]}The check gets less reliable once the lock is behind indirection — embedded several layers deep, copied as a map value, or instantiated through a generic type parameter. Those cases are worth a manual look, not just a clean go vet run.