Skip to content

Defensive coding

FieldValue
TypeSkill Resource
Source~/.copilot/skills/backend/references/safety.md
DescriptionNot 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 array
invoices := make([]domain.Invoice, 2, 4) // len=2, cap=4 — two spare slots
sub := invoices[:1]
sub = append(sub, domain.Invoice{ID: "inv_99"}) // writes into invoices[1], no error, no signal

Force 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 reallocate
sub = 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 returns
func 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 errorfatal 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 A
cache[id] = inv
// goroutine B, running concurrently
_ = cache[id] // fatal error: not a panic, recover() does not help

This 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 range
func 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 raised

Decode 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.