How defer Works Internally in Go

Go's defer executes deferred functions in LIFO order after the surrounding function returns, with arguments evaluated at the time of deferral.

The cleanup that never forgets

You open a file to read a configuration. Halfway through parsing, you hit a malformed line and return an error. The file handle stays open. You add a mutex to protect a shared counter. You return early on a validation failure. The mutex stays locked. The next request hangs forever. These are the moments where manual cleanup fails. You have three return paths, so you need three cleanup calls. Add a panic, and you need four.

Go solves this with defer. You register a function call to run when the surrounding function returns. The call happens automatically, on every exit path, including panics. You write the cleanup once, right next to the resource acquisition, and the runtime guarantees it runs.

How defer captures the world

defer registers a function call to execute when the surrounding function returns. The call runs in Last-In-First-Out order. If you defer three functions, the third one runs first.

The arguments to a deferred call are evaluated immediately when the defer statement executes. The values are frozen at that moment. If the variables change later, the deferred call still sees the old values.

package main

import "fmt"

// PrintValue prints the integer passed to it.
func PrintValue(val int) {
    fmt.Println(val)
}

func main() {
    x := 1
    // Arguments are evaluated now. x is 1.
    // The deferred call captures the value 1.
    defer PrintValue(x)

    x = 2
    // x changes, but the deferred call is already bound to 1.
    // main returns here. The deferred call runs.
}

The output is 1. The assignment x = 2 does not affect the deferred call. The compiler evaluated x at the defer line, stored the value 1, and attached that value to the deferred closure.

If you need the deferred call to see the current value of a variable at execution time, you must wrap the call in a closure. The closure delays the evaluation until the function runs.

package main

import "fmt"

func main() {
    x := 1
    // The closure captures the variable x, not the value.
    // Evaluation happens when the closure runs.
    defer func() {
        fmt.Println(x)
    }()

    x = 2
    // main returns. The closure runs and prints 2.
}

Defer arguments are frozen at the call site. Trust the timing.

What happens under the hood

The compiler rewrites your function to manage a hidden list of deferred calls. Every goroutine maintains a linked list of deferred functions. When the compiler sees a defer statement, it generates code to push the call onto that list.

At every exit point in the function, the compiler inserts code to pop the list and execute the calls. This includes normal returns, early returns, and panics. The runtime walks the list in reverse order and invokes each deferred function.

This mechanism is why defer works with panics. When a panic occurs, the runtime unwinds the stack. Before it destroys the stack frame, it runs all deferred functions. This is the only place where recover can catch a panic. If you call recover inside a deferred function, it stops the panic and returns the panic value. Without defer, there is no safe way to intercept a panic.

The overhead of defer is small but measurable. Pushing to the list and popping it costs a few machine instructions. In a tight loop processing millions of items, that cost adds up. In normal application code, the cost is negligible compared to I/O or logic.

The compiler rejects invalid defer syntax. If you write defer x where x is a variable, you get defer statement must be function or method call. You must defer a function call. You cannot defer a variable assignment or a block of code.

Realistic cleanup patterns

The most common use of defer is pairing resource acquisition with release. You open a resource, immediately defer the cleanup, and then do the work. This keeps the cleanup logic close to the setup and ensures it runs even if you add new error paths later.

package main

import (
    "fmt"
    "os"
)

// ReadFile reads the content of a file and returns it.
// It ensures the file is closed regardless of errors.
func ReadFile(path string) (string, error) {
    f, err := os.Open(path)
    if err != nil {
        return "", err
    }
    // Close the file when this function returns.
    // This runs on success, error, or panic.
    defer f.Close()

    var content string
    // Simulate reading logic.
    // If this returns an error, f.Close() still runs.
    content = "data"
    return content, nil
}

func main() {
    // Example usage.
    data, err := ReadFile("config.txt")
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println(data)
}

Mutexes follow the same pattern. You lock the mutex, defer the unlock, and proceed. This prevents deadlocks when you return early.

package main

import (
    "fmt"
    "sync"
)

var (
    mu    sync.Mutex
    count int
)

// Increment safely increments the counter.
// It ensures the mutex is unlocked on every path.
func Increment() {
    mu.Lock()
    // Unlock the mutex when this function returns.
    // This prevents deadlocks on early returns.
    defer mu.Unlock()

    count++
    fmt.Println(count)
}

Defer is for cleanup, not for logic. Keep deferred functions small.

Pitfalls and traps

Deferred calls can modify named return values. If a function declares named return parameters, the deferred function can access and change them after the return statement executes. The return statement sets the return values, then the deferred functions run, then the function actually exits.

package main

import "fmt"

// AddWithSideEffect adds two numbers and increments a counter via defer.
// The deferred function modifies the named return value.
func AddWithSideEffect(a, b int) (sum int) {
    // The deferred function runs after the return statement sets sum.
    defer func() {
        sum++
    }()

    // return sets sum to a + b.
    // Then the deferred function runs and increments sum.
    return a + b
}

func main() {
    // Prints 6, not 5.
    fmt.Println(AddWithSideEffect(2, 3))
}

This behavior is rarely useful. It makes code harder to read and reason about. The Go community considers modifying named returns via defer to be confusing. Avoid it. Use explicit return values instead.

Loop variables used to be a major trap. In older versions of Go, loop variables were reused across iterations. If you deferred a closure that captured the loop variable, all deferred calls would see the final value of the variable. Go 1.22 fixed this by scoping loop variables to each iteration. The trap is less dangerous now, but the principle remains: closures capture variables, not values. If you need the value at the time of deferral, pass it as an argument.

package main

import "fmt"

func main() {
    for i := 0; i < 3; i++ {
        // Pass i as an argument to capture the current value.
        defer fmt.Println(i)
    }
    // Prints 2, 1, 0.
    // Arguments are evaluated at defer time.
    // Calls run in LIFO order.
}

If you defer a function that panics, the panic propagates. It does not get swallowed. The runtime continues unwinding the stack and runs any remaining deferred functions. If a deferred function recovers the panic, the panic stops and the function returns normally.

The compiler enforces that defer is a call. You cannot write defer fmt.Println(x) + 1. You get defer statement must be function or method call. You must defer a function invocation.

When to use defer

Use defer when you need cleanup that must happen on every exit path, including panics. Use defer when you want to pair resource acquisition with release at the top of the function. Use defer when you are recovering from a panic to restore invariants before the function exits.

Use explicit return statements when you need to control the exact order of cleanup relative to return value assignment. Use inline cleanup code when performance is critical in a tight loop and the overhead of defer matters. Use a closure with defer when you need to capture the current state of a variable that might change later.

Defer is cheap, but not free. Trust the stack. Argue logic, not formatting.

Where to go next