How to Handle Errors in Deferred Functions

Defer statements cannot prevent error returns; handle errors immediately after the call or use defer only for cleanup and logging.

The deferred function trap

You open a database connection, run a query, and add a defer conn.Close() to keep things tidy. The query fails. You want the deferred close to catch that failure, log it, and maybe return a different error to the caller. You write the code, run it, and watch the original error slip through unchanged. The deferred function ran, but it never touched the return value. This is a common tripwire for developers coming from languages where exception handlers or finally blocks can alter control flow. Go handles it differently.

How defer actually schedules work

defer does not intercept return values. It schedules a function call to execute after the surrounding function finishes its work. The surrounding function evaluates its return expressions, hands them to the caller, and only then runs the deferred calls. Errors in Go are just values, not exceptions. A deferred function cannot reach back and change a value that has already been handed off. If you need to modify a return value during cleanup, you must use named return parameters. If you need to log or wrap an error without changing it, you handle it before the return statement or capture it in a variable that the deferred function can read.

Think of defer like a checkout counter at a grocery store. You scan your items, the cashier totals the bill, and hands you the receipt. Only after you take the receipt do you drop your reusable bags into the return bin. The bag drop happens after the transaction completes. It cannot change the total on the receipt you already hold.

The minimal pattern that works

Here is the pattern that looks right but does nothing to the caller.

func readFile(path string) error {
    f, err := os.Open(path)
    if err != nil {
        return err
    }
    // This defer runs after the function returns.
    // It cannot change the error value already sent to the caller.
    defer func() {
        closeErr := f.Close()
        if closeErr != nil {
            // This logs, but the original error still propagates.
            log.Println("failed to close:", closeErr)
        }
    }()

    // ... do work with f ...
    return nil
}

The deferred anonymous function executes after return nil evaluates. The caller already received nil. The deferred function had no mechanism to overwrite it. If you want the deferred cleanup to actually change what gets returned, you need named return values. Named returns let the deferred function reference the return variable by name and mutate it before the function frame fully closes.

Step by step execution

When Go encounters a defer statement, it does not run the function immediately. It pushes the function call onto a stack attached to the current function frame. When the surrounding function reaches a return statement, Go evaluates the return expressions and stores them in the return slots. Then Go walks the defer stack in reverse order and executes each deferred call. Only after all deferred calls finish does the function actually exit and hand control back to the caller.

This execution order matters when you use named return values. Named returns are declared in the function signature and initialized to their zero values. They live in the function frame the entire time. A deferred function can read and write them directly.

func processFile(path string) (err error) {
    f, openErr := os.Open(path)
    if openErr != nil {
        return openErr
    }
    // Named return 'err' is in scope here.
    // The deferred function can read and modify it.
    defer func() {
        closeErr := f.Close()
        if closeErr != nil {
            // If the main logic already failed, wrap the close error.
            if err != nil {
                err = fmt.Errorf("%w: failed to close file: %w", err, closeErr)
                return
            }
            // If main logic succeeded, the close error becomes the return value.
            err = closeErr
        }
    }()

    // ... read and process data ...
    return nil
}

The return nil statement sets the named err to nil. The deferred function runs, calls f.Close(), and checks closeErr. If closeErr is not nil, the deferred function overwrites err with a wrapped error. The function frame then closes, and the caller receives the wrapped error. The cleanup succeeded in altering the outcome because the return variable was named and accessible.

Defer runs in last-in-first-out order. If you defer three functions, they execute in reverse. Keep this in mind when chaining cleanup steps. Close the innermost resource first, then the outer ones.

Real world: database transactions and cleanup

Database transactions show why deferred error handling needs careful design. You begin a transaction, run multiple queries, and want to commit on success or roll back on failure. The rollback must happen even if one of the queries panics or returns an error.

func transferMoney(db *sql.DB, from, to string, amount float64) (err error) {
    tx, beginErr := db.Begin()
    if beginErr != nil {
        return beginErr
    }
    // Defer rollback to guarantee cleanup on any failure path.
    // We use a named return so the defer can check if we already failed.
    defer func() {
        if err != nil {
            // Rollback only if the main logic set an error.
            rollbackErr := tx.Rollback()
            if rollbackErr != nil {
                log.Println("rollback failed:", rollbackErr)
            }
            return
        }
        // Commit only if no error occurred.
        commitErr := tx.Commit()
        if commitErr != nil {
            err = commitErr
        }
    }()

    _, debitErr := tx.Exec("UPDATE accounts SET balance = balance - ? WHERE id = ?", amount, from)
    if debitErr != nil {
        return debitErr
    }

    _, creditErr := tx.Exec("UPDATE accounts SET balance = balance + ? WHERE id = ?", amount, to)
    if creditErr != nil {
        return creditErr
    }

    return nil
}

The deferred function checks the named err variable. If err is not nil, it rolls back and returns early. If err is nil, it attempts a commit. If the commit fails, it overwrites err so the caller knows the transaction did not complete. This pattern keeps the happy path clean while guaranteeing resource cleanup. The community accepts the if err != nil { return err } boilerplate because it makes the unhappy path visible. Wrapping errors with fmt.Errorf("%w", err) preserves the original error chain so callers can use errors.Is or errors.As later. Always put the primary error first in the wrap message. The %w verb marks which error is the root cause.

Pitfalls and silent failures

The biggest trap is treating defer like a finally block that can swallow or replace errors. Go will not stop you from returning a new error inside a deferred function if you use named returns, but it also will not warn you if you accidentally shadow the return variable. If you write err := f.Close() inside the defer instead of err = f.Close(), you create a new local variable. The original named return stays untouched. The compiler accepts this without complaint because variable shadowing is legal. You end up with a silent resource leak or a missing error report. The compiler rejects accidental reassignments with no new variables on left side of := if you try to use := on an already declared variable in the same scope, but inside a nested anonymous function, := always creates a new scope. Watch your assignment operators carefully.

Another common mistake is mixing up panics and errors. Developers sometimes put recover() inside a deferred function hoping to catch normal error returns. recover() only catches panics. If your function returns an error value, recover() returns nil. The compiler will not flag this, but your panic handler will never trigger for routine failures. Keep panic recovery isolated to truly exceptional cases like nil pointer dereferences or out-of-bounds slices. Normal control flow belongs to error values.

You also need to watch out for deferred functions that run even when you return early. defer attaches to the function frame, not to a specific code path. If you return on line five, the defer still runs on line twenty. This is usually what you want for cleanup, but it means your deferred error handler will execute even if the error came from a completely different branch. Always check whether the error you are handling actually belongs to the operation you are cleaning up.

Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path. This rule applies to deferred cleanup too. If your deferred function blocks on a network call or a channel send, the surrounding function will hang until that block resolves. Keep deferred functions fast and side-effect free.

Defer is for cleanup, not control flow. Handle errors where they happen.

When to use what

Use immediate error checking when the failure stops the operation and you need to return right away. Use named return values with defer when you must clean up a resource and potentially wrap or replace the error before the function exits. Use a plain defer with logging when you want to record a cleanup failure without changing the primary return value. Use recover() inside defer only when you expect a panic that would crash the program, not for routine error handling. Use sequential cleanup without defer when the resource lifecycle is short and the code path is straightforward. Use context cancellation when you need to abort a long-running operation before it finishes.

Trust the execution order. Defer runs after return, not before.

Where to go next