Tricky parts of Golang. The defer Trap in Loops

defer is one of Go’s most useful features and one of its most misunderstood. The documentation is precise: a deferred call runs when the surrounding function returns — not when the surrounding block or loop iteration ends. That single sentence is responsible for resource leaks, silent data corruption through named returns, and unnecessary performance overhead that persists until you know to look for it.

Misconception 1: defer Runs at the End of a Block

This is the most common mistake, and it almost always appears inside loops:

func processFiles(paths []string) error {
    for _, path := range paths {
        f, err := os.Open(path)
        if err != nil {
            return err
        }
        defer f.Close() // WRONG: defers until processFiles returns

        if err := process(f); err != nil {
            return err
        }
    }
    return nil
}

This looks reasonable: open the file, defer its close, process it, move on. In reality, none of the files are closed until processFiles returns. With 1000 paths, you hold 1000 open file descriptors simultaneously. On Linux, the default per-process limit is 1024. This code will fail at runtime with “too many open files” on sufficiently large inputs — and in testing with small inputs, it will work perfectly.

The defer stack for this function looks like this:

graph TD
    subgraph stack["defer stack at return — LIFO, top runs first"]
        d1["f[999].Close()"] --> d2["f[998].Close()"] --> d3["···"] --> d4["f[1].Close()"] --> d5["f[0].Close()"]
    end

All defers execute in LIFO order when the function finally returns.

The Fix: Wrap in a Helper Function

Extract the per-iteration work into its own function. Now defer is scoped to that function and runs at the end of each iteration:

func processFiles(paths []string) error {
    for _, path := range paths {
        if err := processOne(path); err != nil {
            return err
        }
    }
    return nil
}

func processOne(path string) error {
    f, err := os.Open(path)
    if err != nil {
        return err
    }
    defer f.Close() // correct: runs when processOne returns

    return process(f)
}

The Fix: Explicit Close in the Loop

For simple cases where a helper function feels like overkill, close explicitly:

for _, path := range paths {
    f, err := os.Open(path)
    if err != nil {
        return err
    }

    err = process(f)
    f.Close() // explicit — runs at end of iteration regardless of err
    if err != nil {
        return err
    }
}

Note the ordering: close before checking the error from process, so the file is always closed even when processing fails.

Misconception 2: Deferred Arguments Are Evaluated Late

Deferred arguments are evaluated immediately at the defer statement, not when the deferred function runs:

func measure(name string) {
    start := time.Now()
    defer fmt.Println(name, "took", time.Since(start)) // time.Since evaluated NOW

    time.Sleep(100 * time.Millisecond)
}

time.Since(start) is evaluated when defer is registered, not when fmt.Println is called. The printed duration will be near-zero regardless of how long the function takes.

To capture a value at function exit, use a closure — closures capture variables by reference:

func measure(name string) {
    start := time.Now()
    defer func() {
        fmt.Println(name, "took", time.Since(start)) // evaluated at function return
    }()

    time.Sleep(100 * time.Millisecond)
}

Misconception 3: Named Returns and defer

Named return values interact with defer in a way that surprises even experienced Go developers. A deferred closure can read and modify named return values after the return statement has set them:

func double(n int) (result int) {
    defer func() {
        result *= 2 // modifies the return value after return
    }()
    return n // sets result = n, then the deferred func doubles it
}

fmt.Println(double(3)) // prints 6, not 3

The return n statement is compiled as two operations: “set result = n” and “jump to function epilogue.” The deferred function runs between those two operations, so it sees and can modify result before the caller receives it.

This is occasionally useful — it is the idiomatic way to wrap errors on the way out:

func readConfig(path string) (err error) {
    f, err := os.Open(path)
    if err != nil {
        return
    }
    defer func() {
        if cerr := f.Close(); cerr != nil && err == nil {
            err = cerr // surface close errors only when there is no earlier error
        }
    }()

    return decode(f)
}

But it is also a trap when naming returns for documentation and forgetting that deferred closures can change them:

func fetchUser(id int) (user *User, err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("panic: %v", r) // silently sets err
        }
    }()

    // ...
    return findUser(id) // if this panics, err is set by the deferred recover
}

The rule: if a function has named returns and deferred closures, the deferred closures can modify the return values. Read them together.

The Execution Model

The full picture of how Go processes a deferred function:

flowchart TD
    A["defer f(args) registered\n— args evaluated immediately\n— f pushed onto defer stack"] --> B["function body continues"]
    B --> C["return statement\n(or panic, or end of function)"]
    C --> D["named return values set\n(if any)"]
    D --> E["deferred functions run LIFO\n(may modify named returns)"]
    E --> F["caller receives return values"]

Performance: Before and After Go 1.14

Before Go 1.14, each defer statement allocated a heap object to record the deferred call. In a tight loop this added meaningful GC pressure. From Go 1.14 onward, the compiler uses open-coded defers: for functions with a small number of defers and no defer inside a loop, the deferred call is inlined directly at each return point, eliminating the allocation entirely.

# Check if open-coded defers apply:
go build -gcflags="-m=2" ./... 2>&1 | grep "open-coded"

The practical takeaway: defer in modern Go (1.14+) is nearly free for typical use. The performance concern is mostly historical, but the semantic concerns above — loop accumulation and named-return mutation — are very much alive.

Quick Reference

Situation Behavior Common Mistake
defer in a loop Defers accumulate until function returns Expecting per-iteration cleanup
defer f(expr) expr evaluated immediately Expecting lazy evaluation
defer func() { ... }() Closure captures variables by reference Forgetting to copy loop variables
Named return + defer closure Closure can modify return value Assuming return x is final

Mental Model

defer is a function-scoped stack, not a block-scoped one. It runs at function exit, not block exit. Everything else follows from that.

When you reach for defer inside a loop, stop and ask: “should this run once per iteration, or once when the entire function returns?” If the answer is once per iteration, either extract a helper function or close explicitly.