Resource cleanup is easy to get right on the happy path and easy to miss on an early return. A function opens a file, acquires a lock, or starts a trace span; a later check fails; the function returns before reaching the cleanup statement.

Go’s defer statement addresses this by scheduling a function call to run when the surrounding function returns. Used well, it places cleanup next to acquisition and makes every return path easier to reason about.

The useful mental model is:

acquire -> immediately schedule cleanup -> do work -> cleanup on function exit

The details matter, though. Deferred calls run in reverse order, their arguments are evaluated when defer executes, cleanup errors can be lost if ignored, and deferring work inside a long-running loop can keep resources alive longer than intended.

Schedule cleanup as soon as ownership begins

Suppose a function opens a configuration file and has several possible return paths:

func loadConfig(path string) ([]byte, error) {
    file, err := os.Open(path)
    if err != nil {
        return nil, err
    }
    defer file.Close()

    data, err := io.ReadAll(file)
    if err != nil {
        return nil, err
    }

    if len(data) == 0 {
        return nil, errors.New("configuration is empty")
    }

    return data, nil
}

Once os.Open succeeds, this function owns an open file descriptor. Scheduling file.Close() immediately after acquisition means later returns do not need to repeat cleanup logic.

This is the central value of defer: it ties cleanup to the lifetime of the surrounding function rather than to one particular control-flow path.

Understand exactly when deferred calls run

A deferred call does not run when the defer statement is encountered. Go evaluates the call and saves it, then invokes it immediately before the surrounding function returns.

That includes returns caused by:

  • an explicit return statement;
  • reaching the end of the function body;
  • stack unwinding because the goroutine is panicking.

For a normal return, result values are established before deferred functions run, and the function does not return to its caller until those deferred functions finish.

This ordering is why a deferred cleanup can reliably release resources on multiple return paths.

Deferred calls run in reverse order

If a function schedules several deferred calls, the most recently deferred call runs first.

func example() {
    defer fmt.Println("first")
    defer fmt.Println("second")
    defer fmt.Println("third")
}

The output is:

third
second
first

This last-in, first-out behavior often matches nested resource acquisition.

For example:

lockA.Lock()
defer lockA.Unlock()

lockB.Lock()
defer lockB.Unlock()

lockB is released before lockA, reversing the acquisition order.

That is a useful default for nested resources, but it is not a substitute for designing a correct locking strategy. If multiple goroutines acquire the same locks in inconsistent orders, defer does not prevent deadlocks.

Arguments are evaluated when defer executes

One of the most important rules is that the function value and arguments of a deferred call are evaluated when the defer statement executes, not when the deferred function later runs.

func printValue() {
    value := 10
    defer fmt.Println(value)

    value = 20
}

This prints 10.

The fmt.Println call is delayed, but its value argument was already evaluated and saved when value was 10.

Use a closure when you need the later value

A deferred function literal can read variables when the deferred function actually executes:

func printLatestValue() {
    value := 10
    defer func() {
        fmt.Println(value)
    }()

    value = 20
}

This prints 20 because the closure reads value when it runs.

Neither form is universally better. The direct call is useful when you intentionally want to capture the current argument. The closure is useful when cleanup needs state that may change before the function returns.

Use defer with mutexes to keep unlock paths local

Locks are a common use case because forgetting one Unlock can block unrelated goroutines indefinitely.

type Counter struct {
    mu    sync.Mutex
    value int
}

func (c *Counter) Increment() {
    c.mu.Lock()
    defer c.mu.Unlock()

    c.value++
}

The ownership rule is clear: once this function acquires the mutex, it schedules the corresponding unlock before doing protected work.

Do not extend a critical section accidentally

defer releases the lock when the surrounding function returns, not when the current block ends.

This can be too broad:

func updateAndNotify() {
    mu.Lock()
    defer mu.Unlock()

    updateSharedState()
    sendSlowNotification()
}

If sendSlowNotification does not require the lock, the mutex remains held unnecessarily while slow work runs.

Prefer a smaller helper or an explicit unlock when the critical section must end before the function does:

func updateAndNotify() {
    updateState()
    sendSlowNotification()
}

func updateState() {
    mu.Lock()
    defer mu.Unlock()

    updateSharedState()
}

The important goal is not to maximize use of defer; it is to make the resource lifetime match the work that actually needs it.

Treat cleanup errors according to the resource contract

This common pattern intentionally ignores the return value from Close:

defer file.Close()

For a read-only file, that may be acceptable when the application has no useful action to take on a close error.

For writable resources, ignoring the final close error can be dangerous. Buffered data or filesystem errors may only become visible during flushing or closing, depending on the API and environment.

If closing is part of determining whether the operation succeeded, preserve that error.

func writeReport(path string, data []byte) (err error) {
    file, err := os.Create(path)
    if err != nil {
        return err
    }

    defer func() {
        closeErr := file.Close()
        if err == nil && closeErr != nil {
            err = closeErr
        }
    }()

    if _, err = file.Write(data); err != nil {
        return err
    }

    return nil
}

The named result lets the deferred function replace a successful result with a close failure. It deliberately keeps the earlier operation error when both writing and closing fail.

That policy should be chosen consciously. Some systems need to preserve both errors, perhaps by joining or wrapping them, rather than selecting one.

Be careful when defer appears inside loops

A deferred call belongs to the surrounding function, not to the nearest loop iteration.

This code keeps every opened file until processFiles returns:

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

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

    return nil
}

With a large input, many file descriptors can remain open simultaneously.

Put one iteration in a helper function

A small helper gives each resource its own function lifetime:

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

func processFile(path string) error {
    file, err := os.Open(path)
    if err != nil {
        return err
    }
    defer file.Close()

    return process(file)
}

Now file.Close() runs at the end of each processFile call instead of after the entire loop completes.

This pattern is often clearer than manually managing several cleanup branches inside the loop body.

Deferred functions still run during a panic

When a function panics, its deferred functions run as the stack unwinds.

That makes defer useful for releasing resources even when normal control flow is interrupted:

mu.Lock()
defer mu.Unlock()

mightPanic()

If mightPanic panics, the deferred unlock still runs before the panic continues to the caller.

This does not mean every panic should be recovered. Cleanup and recovery are separate decisions.

Recover only at a boundary that can respond safely

recover can stop an active panic when it is called directly by a deferred function in the same goroutine’s unwinding path.

func runSafely(fn func()) (panicked bool) {
    defer func() {
        if recover() != nil {
            panicked = true
        }
    }()

    fn()
    return false
}

Recovery can be appropriate at a boundary that can isolate one failed task, convert an internal panic into an application-level failure, or perform controlled reporting.

It is usually a mistake to recover deep inside ordinary business logic merely to keep executing. A panic may indicate violated invariants or partially completed work, and blindly continuing can hide a corrupted state.

Also remember that one goroutine cannot recover a panic occurring in another goroutine. Recovery must happen in the panicking goroutine.

Named results can be changed by deferred functions

Because deferred functions run after result parameters have been set but before control returns to the caller, a deferred closure can inspect or modify named result values.

The close-error example uses this intentionally:

defer func() {
    if closeErr := file.Close(); err == nil && closeErr != nil {
        err = closeErr
    }
}()

This technique is useful when cleanup contributes to the function’s final result.

It can also make control flow harder to follow if deferred functions silently rewrite unrelated return values. Use it when the relationship is direct and documented, not as a general-purpose post-processing mechanism.

A nil deferred function panics when invoked

The function value of a deferred call is evaluated when defer executes. If that function value is nil, the panic occurs later when Go attempts to invoke the deferred function.

func example() {
    var cleanup func()
    defer cleanup()

    fmt.Println("body runs before deferred call")
}

The body reaches its return point, then invocation of the nil deferred function panics.

This is an edge case, but it reinforces the distinction between evaluating and saving a deferred call and executing that call.

Do not use defer to hide unclear ownership

defer works best when the current function clearly owns the resource it schedules for cleanup.

Be cautious when ownership is ambiguous:

func inspect(reader io.Reader) error {
    // Should this function close reader?
}

An io.Reader does not imply a Close method, and even an io.ReadCloser does not automatically tell you which layer owns the responsibility to close it. The API contract must decide that.

A useful rule is:

The component that acquires or accepts ownership should normally be responsible for releasing it.

Once ownership is clear, defer makes that responsibility easier to implement reliably.

Common mistakes

Deferring cleanup before acquisition succeeds

Only schedule cleanup after you know the resource was acquired successfully.

file, err := os.Open(path)
if err != nil {
    return err
}
defer file.Close()

Assuming defer runs at the end of a block

It runs when the surrounding function returns. Use a helper function when a narrower lifetime is required.

Ignoring meaningful close errors

For operations where close or flush completes the write contract, incorporate that failure into the returned result.

Using recover as ordinary error handling

Expected failures should normally travel through explicit error values. Reserve panic recovery for boundaries that can handle the failure safely.

Forgetting immediate argument evaluation

A direct deferred call captures its arguments when defer executes. Use a closure when the cleanup must observe later variable values.

When defer is the right tool

Use defer when cleanup should happen on every exit from the current function and delaying it until function return gives the resource the correct lifetime.

Typical examples include:

  • closing files, response bodies, or other owned closers;
  • unlocking a mutex after a critical section that spans the rest of a helper function;
  • rolling back a transaction unless it has been committed;
  • ending trace spans or timing scopes;
  • restoring temporary process or object state.

Prefer explicit cleanup or a smaller helper when the resource should be released significantly before the surrounding function returns.

Keep cleanup next to ownership

The strongest defer pattern is simple: acquire a resource, check that acquisition succeeded, and immediately schedule the corresponding cleanup.

That structure reduces duplicated cleanup code and protects early-return paths. Its safety still depends on understanding the exact lifetime: deferred calls execute at function exit, in reverse order, with arguments captured when the defer statement executes.

When cleanup can itself fail, decide how that failure contributes to the function’s result. When a loop would retain too many resources, shorten the function scope. When a panic occurs, let deferred cleanup run, but recover only at a boundary that can respond safely.

Used with those rules, defer is not just convenient syntax. It is a practical way to make resource ownership visible and cleanup behavior predictable across the full control flow of a Go function.