Defer and Cleanup

Updated

September 13, 2026

Defer and Cleanup

defer schedules a function call to run when the surrounding function returns — no matter how it returns. The boring default is one line: defer f.Close() right after f is opened. That one line makes early returns, error paths, and normal returns all safe. Clever uses of defer exist; most of them are not worth it.

Mental model

A defer statement pushes a call onto a defer stack. When the surrounding function exits — by return, by falling off the end, or by a panicking runtime — the calls pop off last-in first-out (LIFO). Multiple defers run in reverse order of their appearance.

Arguments are evaluated immediately, at the defer statement, not at call time. The deferred call receives the value that the argument had when defer ran, even if the variable changes later.

x := 1
defer fmt.Println(x)   // captures 1 right now
x = 99
// prints: 1, not 99

Named return values are the exception. A deferred function can read and write the names declared in the result list. That is the one way a deferred call can change what the caller receives. Everything else about named results is optional style; this interaction is their real job.

The defer stack is per-function, not per-block. A defer inside a for loop is attached to the enclosing function, not to the loop body. Files deferred in a loop do not close until the whole function returns.

Worked examples

Case 1: Closing a file — without defer, then with

The leak first. Save as ticket_log_leak.go. writeTicket opens a temp file and writes a line. If the write fails, the early return leaks the open file handle.

// ticket_log_leak.go
package main

import (
    "fmt"
    "os"
)

// writeTicketLeaky opens a file and may leak it on error.
func writeTicketLeaky(id int, note string) error {
    f, err := os.CreateTemp("", "ticket-*.log")
    if err != nil {
        return fmt.Errorf("open: %w", err)
    }
    // BUG: if Fprintf fails, we return without closing f.
    if _, err := fmt.Fprintf(f, "ticket %d: %s\n", id, note); err != nil {
        return fmt.Errorf("write ticket %d: %w", id, err)
    }
    fmt.Println("wrote", f.Name())
    return f.Close()
}

func main() {
    if err := writeTicketLeaky(42, "printer jam"); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

Run:

go run ticket_log_leak.go

Output:

wrote /tmp/ticket-3421760645.log

It works here — but the file handle leaks whenever the write fails. On a busy desk creating thousands of ticket logs, the process runs out of file descriptors.

Now the fix. Save as ticket_log.go. One defer right after the file opens. Every return path is covered.

// ticket_log.go
package main

import (
    "fmt"
    "os"
)

func writeTicket(id int, note string) error {
    f, err := os.CreateTemp("", "ticket-*.log")
    if err != nil {
        return fmt.Errorf("open: %w", err)
    }
    defer f.Close() // runs on every exit path from here

    if _, err := fmt.Fprintf(f, "ticket %d: %s\n", id, note); err != nil {
        return fmt.Errorf("write ticket %d: %w", id, err)
    }
    fmt.Println("wrote", f.Name())
    return nil
}

func main() {
    if err := writeTicket(42, "printer jam"); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

Run:

go run ticket_log.go

Output:

wrote /tmp/ticket-2089451234.log

The rule: open a resource, check the error, then immediately defer its cleanup before doing anything else. Never let code grow between the open and the defer.

Case 2: Unlock after lock — a shared ticket counter

The desk tracks open tickets in a shared counter. Multiple goroutines update it. Save as ticket_counter.go. defer mu.Unlock() right after mu.Lock() guarantees the lock releases on every path, including panics that recover elsewhere.

// ticket_counter.go
package main

import (
    "fmt"
    "sync"
)

type Desk struct {
    mu     sync.Mutex
    open   int
    closed int
}

func (d *Desk) Open() {
    d.mu.Lock()
    defer d.mu.Unlock()
    d.open++
}

func (d *Desk) Close() {
    d.mu.Lock()
    defer d.mu.Unlock()
    if d.open > 0 {
        d.open--
    }
    d.closed++
}

func (d *Desk) Stats() (open, closed int) {
    d.mu.Lock()
    defer d.mu.Unlock()
    return d.open, d.closed
}

func main() {
    var d Desk
    var wg sync.WaitGroup

    for range 50 {
        wg.Add(1)
        wg.Go(func() {
            defer wg.Done()
            d.Open()
            d.Close()
        })
    }
    wg.Wait()

    open, closed := d.Stats()
    fmt.Printf("open: %d  closed: %d\n", open, closed)
}

Run:

go run ticket_counter.go

Output:

open: 0  closed: 50

Writing mu.Lock(); defer mu.Unlock() on two adjacent lines is a pattern: never put anything between them. If you forget defer and an early return skips the Unlock, every subsequent Lock blocks forever.

Case 3: defer in a loop — the fix

Processing a batch of shift reports means opening each file, reading it, and closing it before moving to the next. If you defer inside the loop, none of the files close until the function returns. On a large batch that exhausts the process file descriptor limit. Save as shift_report.go.

// shift_report.go
package main

import (
    "fmt"
    "os"
)

// processShifts opens each report file and reads its size.
// The loop body is an inner function so defer closes the file
// after each iteration, not after the whole batch.
func processShifts(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 fmt.Errorf("open shift report: %w", err)
    }
    defer f.Close() // closes after processOne returns, i.e. each iteration

    info, err := f.Stat()
    if err != nil {
        return fmt.Errorf("stat %s: %w", path, err)
    }
    fmt.Printf("shift report %s: %d bytes\n", path, info.Size())
    return nil
}

func main() {
    // Create two temp files to act as shift reports.
    a, _ := os.CreateTemp("", "shift-*.txt")
    fmt.Fprintln(a, "shift A: 8h")
    a.Close()

    b, _ := os.CreateTemp("", "shift-*.txt")
    fmt.Fprintln(b, "shift B: 6h")
    b.Close()

    if err := processShifts([]string{a.Name(), b.Name()}); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }

    os.Remove(a.Name())
    os.Remove(b.Name())
}

Run:

go run shift_report.go

Output:

shift report /tmp/shift-1027364518.txt: 12 bytes
shift report /tmp/shift-2093847610.txt: 10 bytes

The pattern: extract the per-iteration work into its own named function (processOne). The defer inside that function closes the file after each call. The loop itself stays clean.

Case 4: Named results and defer — adding context to errors

A deferred function that modifies a named return value changes what the caller receives. This is the one place where named returns earn their keep: annotating errors at the exit point without repeating fmt.Errorf on every path. Save as open_ticket.go.

// open_ticket.go
package main

import (
    "errors"
    "fmt"
    "os"
)

var ErrFull = errors.New("desk is full")

// openTicket returns a named error so the deferred annotator can wrap it.
func openTicket(id int, deskSize int) (err error) {
    defer func() {
        if err != nil {
            // err is the named result; writing to it changes what the caller sees.
            err = fmt.Errorf("openTicket(%d): %w", id, err)
        }
    }()

    if id <= 0 {
        return fmt.Errorf("id must be positive, got %d", id)
    }
    if deskSize <= 0 {
        return ErrFull
    }

    fmt.Printf("ticket %d opened, desk has %d slots\n", id, deskSize)
    return nil
}

func main() {
    // Happy path.
    if err := openTicket(7, 3); err != nil {
        fmt.Fprintln(os.Stderr, err)
    }

    // Bad id.
    if err := openTicket(-1, 3); err != nil {
        fmt.Fprintln(os.Stderr, err)
    }

    // Desk full — wrapping preserves the sentinel.
    err := openTicket(8, 0)
    fmt.Println("is ErrFull:", errors.Is(err, ErrFull))
    fmt.Fprintln(os.Stderr, err)
}

Run:

go run open_ticket.go

Output:

ticket 7 opened, desk has 3 slots
openTicket(-1): id must be positive, got -1
is ErrFull: true
openTicket(8): desk is full

Three things to notice:

  1. The function signature is (err error) — named result.
  2. The deferred closure reads err after the return statement has set it.
  3. errors.Is still works because the annotator uses %w, which wraps rather than replaces.

Wrapping preserves the sentinel so callers can still test errors.Is(err, ErrFull). If you used fmt.Errorf("... %v", err) instead of %w, the wrapping would be lost.

When to use this pattern. Only when every error from the function needs the same context prefix. If only some errors need it, add fmt.Errorf at each return.

The trap

Deferring inside a loop without an inner function. The deferred calls pile up and execute only when the enclosing function returns. Save as loop_leak.go to see the problem:

// loop_leak.go
package main

import (
    "fmt"
    "os"
)

// BUG: all files stay open until dumpReports returns.
func dumpReports(paths []string) {
    for _, path := range paths {
        f, err := os.Open(path)
        if err != nil {
            fmt.Fprintln(os.Stderr, err)
            continue
        }
        defer f.Close() // deferred to dumpReports, not to the loop iteration
        info, _ := f.Stat()
        fmt.Printf("%s: %d bytes\n", path, info.Size())
    }
    // All f.Close() calls run here, after all files are already open at once.
}

func main() {
    a, _ := os.CreateTemp("", "report-*.txt")
    fmt.Fprintln(a, "data")
    a.Close()

    b, _ := os.CreateTemp("", "report-*.txt")
    fmt.Fprintln(b, "data")
    b.Close()

    dumpReports([]string{a.Name(), b.Name()})
    os.Remove(a.Name())
    os.Remove(b.Name())
}

Run:

go run loop_leak.go

Output:

/tmp/report-1234567890.txt: 5 bytes
/tmp/report-9876543210.txt: 5 bytes

The output looks correct, but both files are open simultaneously throughout dumpReports. With a thousand shift reports that is a thousand open file descriptors at once. The fix from Case 3 applies: move the body into a separate function and call it from the loop.

The boring rule

  • defer for cleanup: Close, Unlock, cancel. Place it immediately after the resource opens, before any other logic.
  • Never put code between the open and the defer.
  • Do not defer inside a tight loop. Handles pile up until the enclosing function returns. Extract the loop body into a named function instead.
  • Use the named-result + defer pattern only when every error from a function needs the same annotation prefix.
  • Prefer a flat return f.Close() at the end of a function when there is only one exit path and no error to annotate — it is simpler and the close error is not silently discarded.
  • Arguments to defer are captured at the defer line. If you need the value at exit time, use a closure (defer func() { use(x) }()).
  • No clever logic in deferred calls. A deferred function should do one obvious thing.

Try this

  1. ticket_log.go: Add a second deferred call that prints "cleanup done for ticket N" before defer f.Close(). Confirm which prints first — the close or the message — and explain why.

  2. ticket_counter.go: Add a Reset method that sets both counters to zero behind the mutex. Run with go run -race ticket_counter.go. Confirm no data race is reported.

  3. shift_report.go: Change processOne to return the file size as a second result (int64, error). Accumulate the total bytes across all files in processShifts and print a summary line.

  4. open_ticket.go: Add a condition id > 9999 that returns fmt.Errorf("id %d exceeds max", id). Confirm the annotator wraps it. Then change the annotator from %w to %v and show that errors.Is(err, ErrFull) now returns false.