Defer and Cleanup
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.goOutput:
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.goOutput:
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 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.goOutput:
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.goOutput:
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:
- The function signature is
(err error)— named result. - The deferred closure reads
errafter thereturnstatement has set it. errors.Isstill 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.goOutput:
/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
deferfor 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
deferinside 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
deferare 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
ticket_log.go: Add a second deferred call that prints"cleanup done for ticket N"beforedefer f.Close(). Confirm which prints first — the close or the message — and explain why.ticket_counter.go: Add aResetmethod that sets both counters to zero behind the mutex. Run withgo run -race ticket_counter.go. Confirm no data race is reported.shift_report.go: ChangeprocessOneto return the file size as a second result(int64, error). Accumulate the total bytes across all files inprocessShiftsand print a summary line.open_ticket.go: Add a conditionid > 9999that returnsfmt.Errorf("id %d exceeds max", id). Confirm the annotator wraps it. Then change the annotator from%wto%vand show thaterrors.Is(err, ErrFull)now returnsfalse.