Defer in Go: concepts, trade-offs and production lessons
Defer is one of Go's most elegant features for resource management, yet it's often misunderstood in production systems. When you defer a function call, Go guarantees it will run when the enclosing function exits—whether by return, panic, or reaching the end. We'll walk through the mechanics, real-world patterns, and the gotchas we've learned the hard way.
Runs on return — Deferred calls run when the function exits, in reverse order
The core guarantee of defer is simple: a deferred call executes when its enclosing function exits, in reverse order (last-deferred, first-executed). This makes defer ideal for cleanup that must always happen, even if an error occurs partway through.
Consider a database transaction handler:
func ProcessPayment(ctx context.Context, userID int64, amount float64) error {
tx, err := db.BeginTx(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback() // Always rolls back if not committed
// Update user balance
if err := tx.Update(userID, amount); err != nil {
return err // Rollback still runs
}
// Charge payment
if err := chargeCard(amount); err != nil {
return err // Rollback still runs
}
// If we get here, commit wins
return tx.Commit()
}Because we defer the rollback immediately after opening the transaction, we're guaranteed to clean up even if we return early. The rollback will be a no-op if we've already committed, and that's fine—it's idempotent.
The reverse-order execution matters when you have multiple defers:
func MultiResourceCleanup() error {
a, _ := openA()
defer a.Close() // Deferred third (runs second)
b, _ := openB()
defer b.Close() // Deferred second (runs first)
c, _ := openC()
defer c.Close() // Deferred first (runs last)
return doWork()
// When exiting: c.Close() → b.Close() → a.Close()
}This LIFO (last-in-first-out) order prevents dependency issues. If B depends on C being open, C's cleanup runs after B's, so the dependency is intact throughout.
Close resources — defer f.Close() right after opening
In production, the pattern is mechanical: open a resource, defer its cleanup, then proceed. This is safer than manual cleanup because it's impossible to forget.
File handling is the classic case:
func ReadConfig(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close() // Always runs, prevents fd leak
return io.ReadAll(f)
}Even if io.ReadAll fails, the file descriptor is closed. Over hundreds of requests, forgetting this adds up to crashed services.
The same pattern applies to database connections, mutexes, and temporary resources:
func FetchUser(ctx context.Context, id int64) (*User, error) {
row := db.QueryRowContext(ctx, "SELECT id, name FROM users WHERE id = ?", id)
// Even if Scan fails, the underlying rows are cleaned up
var user User
if err := row.Scan(&user.ID, &user.Name); err != nil {
return nil, err
}
return &user, nil
}
func CriticalSection(mu *sync.Mutex) {
mu.Lock()
defer mu.Unlock() // Guaranteed release, even on panic
// Critical work here
}The mutex example is particularly important: if your critical section panics, defer ensures the lock is released so other goroutines aren't permanently blocked.
Arguments evaluate early — Values are captured when defer is declared
This is where defer surprises many engineers. When you defer a call, the arguments are evaluated immediately, not when the deferred function runs. The function body runs later, but the arguments are bound now.
func DeferArgsEvaluateEarly() {
x := 1
defer fmt.Println("x is", x) // x is evaluated here
x = 2
x = 3
// Output: x is 1
}
func WrongRetryLogic() {
attempts := 0
for i := 0; i < 3; i++ {
defer fmt.Println("Attempt", attempts) // Evaluated each iteration
attempts++
}
// Output: Attempt 0, Attempt 1, Attempt 2
// (in reverse order: 2, 1, 0)
}This is useful for logging and debugging, but it can cause bugs if you're not careful. A common pattern is deferring a function that reads the current state:
func TimedOperation(operation string) {
start := time.Now()
defer func() {
// This function body runs later, so it sees the final state
fmt.Printf("%s took %v\n", operation, time.Since(start))
}()
// Work here
time.Sleep(100 * time.Millisecond)
}By wrapping the logic in an anonymous function, we delay evaluation until cleanup time. This is the standard pattern when you need to capture late-binding values.
Avoid in hot loops — Deferring inside large loops delays cleanup until the function ends
Defer carries overhead: each deferred call is recorded in a stack. In tight loops processing millions of items, deferred cleanup can exhaust memory and trash performance.
Consider batch processing:
// WRONG: Defers accumulate
func ProcessBadge(items []Item) error {
for _, item := range items {
f, _ := os.Open(item.Path)
defer f.Close() // Deferred 1M times!
data, _ := io.ReadAll(f)
process(data)
}
return nil
}
// RIGHT: Explicit cleanup per iteration
func ProcessGood(items []Item) error {
for _, item := range items {
f, _ := os.Open(item.Path)
data, _ := io.ReadAll(f)
f.Close() // Cleanup happens immediately
process(data)
}
return nil
}The first version defers 1 million close calls, keeping file descriptors open until the function exits. The system runs out of fds or memory. The second version closes each file before opening the next, keeping resource usage constant.
The pattern is to extract a helper function and let defer work within the smaller scope:
func ProcessBetter(items []Item) error {
for _, item := range items {
if err := processOne(item); err != nil {
return err
}
}
return nil
}
func processOne(item Item) error {
f, err := os.Open(item.Path)
if err != nil {
return err
}
defer f.Close() // Only one defer per iteration
data, _ := io.ReadAll(f)
return process(data)
}Now the defer cleanup happens in the helper, which runs and completes for each item. The overhead is predictable and bounded.
Common mistakes
Ignoring errors in deferred calls: A deferred cleanup function can fail. If you defer a close that returns an error, that error is lost. Decide explicitly whether to log, return, or ignore it:
// Logs but doesn't block
defer func() {
if err := f.Close(); err != nil {
log.Printf("close failed: %v", err)
}
}()
// Better for critical resources: return the error
defer func() {
if err := tx.Rollback(); err != nil && err != sql.ErrTxDone {
// Handle gracefully
}
}()Deferred cleanup doesn't run on os.Exit: If your code calls os.Exit(), defer doesn't run. Use this rarely, and only for fatal errors where cleanup is truly impossible. Prefer returning errors and letting main handle shutdown.
Forgetting that defer introduces a function boundary: Deferred functions don't have access to the enclosing function's named return values unless you use a pointer or a closure:
func BadReturn() (result string, err error) {
defer func() {
result = "modified" // WRONG: modifies local copy
}()
return "original", nil
}
func GoodReturn() (result string, err error) {
defer func() {
result = "modified" // RIGHT: closure captures named return by reference
}()
return "original", nil
}Key takeaways
- Defer runs on exit (return, panic, or end of function) in reverse order—use it for cleanup that must always happen.
- Place defer immediately after opening a resource; it's idiomatic and prevents fd leaks and deadlocks.
- Remember that defer arguments evaluate immediately; use a closure to defer late-binding reads (like elapsed time).
- Never defer inside large loops. Extract a helper function and let defer work in a smaller scope.
- Deferred error returns are invisible; wrap them explicitly in a function if the error matters.
- Defer is not a replacement for explicit cleanup in performance-critical paths; it's a safety net for the common case.






Comments
Loading comments…