DEV Community

Cover image for Go's Defer: Simple Rules, Deep Runtime Truths with intuitions.
Saiful Islam
Saiful Islam

Posted on

Go's Defer: Simple Rules, Deep Runtime Truths with intuitions.

Introduction

defer schedules a function call to run just before the surrounding function returns. If you think it's just "a function that runs at the end," you're missing the important details about how Go actually executes your code.

This guide explains what defer really does and how it works at the runtime level.


Quick Mental Model

Before diving deep, understand this fundamental truth:

  • return x (unnamed): The value is copied immediately, frozen in a register
  • return with named result: The variable stays mutable until the function exits

This single insight explains 90% of defer behavior.


What defer Does

defer stores a function call and executes it when the function returns.

func basicExample() {
    defer fmt.Println("This runs last")
    fmt.Println("This runs first")
    // Output:
    // This runs first
    // This runs last
}
Enter fullscreen mode Exit fullscreen mode

What actually happens:

  1. The function call is stored (not executed)
  2. Execution continues normally
  3. At return time, all deferred calls execute

Argument Evaluation vs Execution Time

Arguments are evaluated immediately, not when the deferred function runs.

func argumentTiming() {
    i := 1
    defer fmt.Println("Deferred print:", i)  // i is evaluated NOW (value 1)
    i = 2
    // Output: Deferred print: 1
}
Enter fullscreen mode Exit fullscreen mode

The value 1 is captured when defer is encountered. Changing i later has no effect.


Stack Frames and Execution

When a function is called:

  1. A stack frame is created
  2. Local variables are allocated
  3. A defer list pointer is attached to the frame

When defer is encountered, the function call is stored in the defer list:

┌─────────────────────┐
│    Stack Frame      │
├─────────────────────┤
│ Local Variables     │
│ Return Address      │
│ Defer List Pointer──┼──→┌─────────────┐
└─────────────────────┘   │  Defer Node │
                          ├─────────────┤
                          │ Function    │
                          │ Arguments   │
                          │ Next ───────┼─→ [More Defers]
                          └─────────────┘
Enter fullscreen mode Exit fullscreen mode

LIFO Order: Last-In, First-Out

Multiple defer statements execute in reverse order:

func lifoExample() {
    defer fmt.Println("First")
    defer fmt.Println("Second")
    defer fmt.Println("Third")

    // Output:
    // Third
    // Second
    // First
}
Enter fullscreen mode Exit fullscreen mode

The last defer added is the first one to execute.


Named vs Unnamed Return Values

This is the core concept. Named and unnamed returns behave differently with defer.

Unnamed Return (Value is Captured)

func unnamedReturn() int {
    x := 5
    defer func() {
        x = x + 10  // Too late to affect return value
    }()
    return x  // Value 5 is captured here
}
// Returns: 5
Enter fullscreen mode Exit fullscreen mode

When return x executes, Go immediately captures the value 5. Deferred functions run after, but they can't change what's already captured.

Named Return (Variable is Shared)

func namedReturn() (result int) {
    defer func() {
        result = result + 10  // Modifies the return variable!
    }()
    result = 5
    return // Returns the modified value
}
// Returns: 15
Enter fullscreen mode Exit fullscreen mode

result is a variable in the stack frame that deferred functions can access and modify until the function actually returns.


Deep Dive: Named vs Unnamed Returns with Closures

Example 1: Named Return

package main

import "fmt"

func calculate() (result int) {
    result = 0
    fmt.Println("first", result)

    show := func() {
        result = result + 10
        fmt.Println("defer", result)
    }
    defer show()

    result = 5
    p := func(a int) {
        fmt.Println("me", a)
    }
    defer p(result)

    defer fmt.Println(result)

    fmt.Println("second", result)

    defer fmt.Println(5)

    return result
}

func main() {
    fmt.Println("calculate result:", calculate())
}
Enter fullscreen mode Exit fullscreen mode

Output:

first 0
second 5
5
me 5
5
defer 15
calculate result: 15
Enter fullscreen mode Exit fullscreen mode

Explanation:

  1. result = 0 → prints "first 0"
  2. defer show() → closure registered (captures reference to result)
  3. result = 5
  4. defer p(result) → function called with value 5 (argument captured immediately)
  5. defer fmt.Println(result) → value 5 captured
  6. defer fmt.Println(5) → literal 5
  7. fmt.Println("second", result) → prints "second 5"
  8. return result → return statement reached

Defers execute in LIFO order:

  • defer fmt.Println(5) → "5"
  • defer fmt.Println(result) → "5"
  • defer p(result) → "me 5"
  • defer show() → closure modifies result to 15, prints "defer 15"

Return value becomes 15 because the named return variable was modified by the closure before the function actually returned.

Example 2: Named vs Unnamed Comparison

package main

import "fmt"

// Named return: defer can modify it
func calculate() (result int) {
    result = 0
    fmt.Println("first", result)

    show := func() {
        result = result + 10
        fmt.Println("defer", result)
    }
    defer show()

    result = 5
    fmt.Println("second", result)
    return result
}

// Unnamed return: defer cannot modify it
func calc() int {
    result := 0
    fmt.Println("first", result)

    show := func() {
        result = result + 10
        fmt.Println("defer", result)
    }
    defer show()

    result = 5
    fmt.Println("second", result)
    return result
}

func main() {
    fmt.Println("calculate result:", calculate())
    fmt.Println("calc result:", calc())
}
Enter fullscreen mode Exit fullscreen mode

Output:

first 0
second 5
defer 15
calculate result: 15
first 0
second 5
defer 15
calc result: 5
Enter fullscreen mode Exit fullscreen mode

The Difference:

Aspect calculate() (Named) calc() (Unnamed)
Return Value 15 5
What Happens Closure modifies return variable Closure modifies local variable
Return Mechanism Returns modified variable Returns captured value

Execution Timeline

Named Return (calculate()):

1. Enter function → result = 0 (named return in stack frame)
2. Register defer show() → closure captures reference to 'result'
3. Set result = 5
4. Return statement reached → process defer list
5. Execute show() → result = 15
6. Return final value of 'result' = 15
Enter fullscreen mode Exit fullscreen mode

Unnamed Return (calc()):

1. Enter function → result = 0 (local variable)
2. Register defer show() → closure captures reference to 'result'
3. Set result = 5
4. Return statement reached → value 5 captured in register
5. Process defer list → execute show() → result = 15 (local changed)
6. Return value from register = 5 (unchanged!)
Enter fullscreen mode Exit fullscreen mode

Understanding Closures in Defer

Deferred closures capture variables by reference, not by value:

func closureExample() (result int) {
    defer func() {
        result = result * 2  // Shares the same memory address
    }()

    result = 5
    return  // Returns 10, not 5
}
Enter fullscreen mode Exit fullscreen mode

The closure has access to the actual result variable and can modify it.


Reference vs Copy Semantics

Scenario Behavior Example
Direct value in defer Copy defer fmt.Println(x)
Closure capturing variable Reference defer func() { use(x) }()
Named return variable Reference func f() (x int)
Unnamed return value Copy (captured) return x
func referenceVsCopy() {
    x := 1
    defer fmt.Println("Copy:", x)  // Captures value 1
    x = 2

    y := 1
    defer func() {
        fmt.Println("Reference:", y)  // Will see y=2
    }()
    y = 2
}
// Output:
// Reference: 2
// Copy: 1
Enter fullscreen mode Exit fullscreen mode

How Go Implements defer Internally

Go implements defer using a singly linked list where each function's stack frame maintains a pointer to a chain of defer records.

Defer Record Allocation

When defer is encountered, Go:

  1. Creates a new defer record (stack- or heap-allocated depending on escape analysis)
  2. Stores the function pointer and captured arguments
  3. Links it to the defer chain starting from the most recent addition

This means each defer statement prepends to the chain, naturally achieving LIFO order.

Defer Record Structure

A conceptual view of the defer record structure:

// Simplified conceptual structure
// (actual runtime definitions vary by Go version)
type _defer struct {
    fn   func()       // Function to call
    args []uintptr    // Captured argument values
    next *_defer      // Pointer to next defer in chain
}
Enter fullscreen mode Exit fullscreen mode

Note: This is a simplified conceptual structure; actual runtime definitions vary by Go version and may include additional fields for panic handling and frame pointers.

Execution Flow

Stack Frame
├─ Local Variables
├─ Return Address
└─ Defer List Pointer ──→ [Defer Record #3] (most recent)
                          ├─ Function: show()
                          ├─ Arguments: captured values
                          └─ Next ──→ [Defer Record #2]
                                      ├─ Function: p()
                                      ├─ Arguments: 5
                                      └─ Next ──→ [Defer Record #1]
                                                  ├─ Function: fmt.Println()
                                                  ├─ Arguments: 5
                                                  └─ Next ──→ NULL
Enter fullscreen mode Exit fullscreen mode

When the function returns, execution starts at the head (most recently added defer) and follows the chain:

func example() {
    defer fmt.Println("1")  // Record 1
    defer fmt.Println("2")  // Record 2 (becomes head)
    defer fmt.Println("3")  // Record 3 (new head)

    // At return:
    // Head → Record 3 (execute) → Record 2 (execute) → Record 1 (execute) → NULL

    // Output:
    // 3
    // 2
    // 1
}
Enter fullscreen mode Exit fullscreen mode

Why This Design?

  • Per-function management: Each function tracks its own defer chain independently
  • Dynamic size: No fixed limit on defer count; scales with runtime behavior
  • O(1) insertion: Adding a new defer is a single pointer operation
  • Natural LIFO: Prepending to the head automatically gives reverse execution order
  • Closure support: Each record can capture different closure environments
  • Panic handling: The defer chain is traversed even during panic recovery

Common Pitfalls

Loop Variable Capture

// WRONG: All closures see final value of i
func problematicLoop() {
    for i := 0; i < 3; i++ {
        defer func() {
            fmt.Println(i)  // Prints: 3, 3, 3
        }()
    }
}

// RIGHT: Pass as parameter to capture value
func fixedLoop() {
    for i := 0; i < 3; i++ {
        defer func(n int) {
            fmt.Println(n)  // Prints: 2, 1, 0
        }(i)
    }
}
Enter fullscreen mode Exit fullscreen mode

Resource Cleanup in Loops

// WRONG: All Close() calls happen at end of function
func processFiles(filenames []string) {
    for _, name := range filenames {
        f, _ := os.Open(name)
        defer f.Close()  // Accumulates
    }
}

// RIGHT: Use a wrapper function for scope
func processFilesCorrect(filenames []string) {
    for _, name := range filenames {
        func() {
            f, _ := os.Open(name)
            defer f.Close()  // Closes immediately after this iteration
        }()
    }
}
Enter fullscreen mode Exit fullscreen mode

Error Handling in Defer

func mightFail() (err error) {
    f, err := os.Open("file.txt")
    if err != nil {
        return err
    }
    defer func() {
        if closeErr := f.Close(); closeErr != nil {
            if err == nil {
                err = closeErr  // Combine errors with named return
            }
        }
    }()

    // Process file...
    return nil
}
Enter fullscreen mode Exit fullscreen mode

Practical Patterns

Resource Cleanup (Named Return)

func openResource() (err error) {
    r, err := acquireResource()
    if err != nil {
        return
    }

    defer func() {
        if closeErr := r.Close(); closeErr != nil && err == nil {
            err = closeErr  // Can modify named return
        }
    }()

    // Use resource...
    return nil
}
Enter fullscreen mode Exit fullscreen mode

Panic Recovery

func safeOperation() (err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("panic: %v", r)
        }
    }()

    // Code that might panic...
    return nil
}
Enter fullscreen mode Exit fullscreen mode

Transaction Pattern

func transaction() error {
    tx := db.Begin()
    defer func() {
        if r := recover(); r != nil {
            tx.Rollback()
            panic(r)
        }
    }()

    // Do work...
    return tx.Commit().Error
}
Enter fullscreen mode Exit fullscreen mode

Performance Considerations

Defer is cheap for most use cases. However, in very tight loops with millions of iterations, the overhead of managing the defer chain becomes measurable:

// Slower: defer in tight loop (millions of iterations)
func processBatch1(items []Item) {
    for _, item := range items {
        mu.Lock()
        defer mu.Unlock()  // Creates millions of deferred records
        process(item)
    }
}

// Faster: explicit unlock (millions of iterations)
func processBatch2(items []Item) {
    for _, item := range items {
        mu.Lock()
        process(item)
        mu.Unlock()  // Immediate cleanup, no defer overhead
    }
}
Enter fullscreen mode Exit fullscreen mode

When to use defer:

  • One-time resource cleanup (file opens, lock acquisition)
  • Error handling and panic recovery
  • Any non-performance-critical code

When to avoid defer:

  • Extremely hot loops with millions of iterations
  • Real-time systems with strict latency requirements

Modern Go (1.20+) has optimized defer significantly with open-coded defers, making it even cheaper in most scenarios.


Top comments (0)