Closures, Defer, and Panic in Go
Understand Go closure capture, deferred evaluation, cleanup lifetimes, and panic recovery with runnable examples and practical guidance for backend code.
A deferred log prints an old value. Another deferred log prints the updated value. A file is eventually closed, but only after a long loop has opened hundreds more. A recovery handler runs, yet the statements after the failed call never execute.
These behaviours become predictable when you separate three questions: which variable is shared, when an expression is evaluated, and which function owns the cleanup or recovery boundary?
This is Part 8 of Go Under the Hood, following Methods, Interfaces, and Typed Nil. You should know functions, pointers, and ordinary error returns. The examples use Go 1.27, verified with Go 1.27.1 on macOS ARM64 using only the standard library. These are language-behaviour experiments, not performance benchmarks.
Experiment 1: a closure shares a variable
A closure can use variables from its enclosing function. It does not automatically freeze their values when created. The captured variables remain accessible for as long as they are needed. Go specification: function literals
Create a module using a Go 1.27 toolchain:
go version
mkdir closure-demo
cd closure-demo
go mod init example.com/closure-demo
go mod edit -go=1.27.0
Save this as main.go:
package main
import "fmt"
func counter() func() int {
n := 0
return func() int {
n++
return n
}
}
func main() {
n := 10
live := func() int { return n }
saved := n
snapshot := func() int { return saved }
n = 20
fmt.Println("live/snapshot:", live(), snapshot())
a, b := counter(), counter()
fmt.Println("counters:", a(), a(), b())
}
Run each complete program in this article by replacing main.go, then running:
go run .
go vet .
Output:
live/snapshot: 20 10
counters: 1 2 1
live reads n when called. snapshot reads a separate variable, saved, which this program never changes. Each call to counter creates independent state that its returned function can continue using.
This is useful for a small callback with private state, such as a test sequence generator. When several operations need to share and inspect that state, a struct with methods may make ownership clearer. A closure also provides no synchronisation: calling a mutating closure concurrently requires a separate concurrency design.
A stored callback that still needs a captured buffer can keep that buffer reachable. If a long-lived callback only needs a small identifier, capture that identifier instead of retaining a whole request.
The lifetime guarantee does not specify stack or heap placement. Avoid claiming that every closure allocates; later parts will inspect compiler decisions and measure allocations.
Loop capture in Go 1.27
For modules declaring Go 1.22 or later, loop variables declared with := have per-iteration semantics. Assigning into an existing variable with = still reuses that variable. The module’s language version matters, not just the installed compiler. Go blog: loop-variable changes
Replace main.go:
package main
import "fmt"
func main() {
var separate, shared []func() int
for _, v := range []int{10, 20, 30} {
separate = append(separate, func() int { return v })
}
var v int
for _, v = range []int{10, 20, 30} {
shared = append(shared, func() int { return v })
}
fmt.Println("declared:", separate[0](), separate[1](), separate[2]())
fmt.Println("assigned:", shared[0](), shared[1](), shared[2]())
}
Output:
declared: 10 20 30
assigned: 30 30 30
No goroutines are needed to expose this distinction. The callbacks simply run after the loops finish. When reviewing delayed work, inspect the variable declaration before adding an extra copy.
Experiment 2: defer evaluates now and calls later
The function and arguments of a deferred call are evaluated when execution reaches defer. Calls run in reverse registration order before the surrounding function returns. A deferred closure can instead read a variable at execution time. Go specification: defer statements
package main
import "fmt"
func calculate() (result int) {
n := 1
defer fmt.Println("argument:", n)
defer func() { fmt.Println("closure:", n) }()
defer func() { result++ }()
n = 2
return 40
}
func main() {
fmt.Println("returned:", calculate())
}
Output:
closure: 2
argument: 1
returned: 41
Trace it as a sequence: save 1 for the first call; register two closures; change n; assign 40 to the named result; run the defers backwards; deliver 41 to the caller. Named results can be changed by a deferred function before the caller receives them. Go blog: defer rules
For timing code, this distinction matters. defer record(time.Since(start)) computes the duration at registration. defer func() { record(time.Since(start)) }() computes it during cleanup. Here, record represents your reporting function.
| Form | What is retained | Useful when |
|---|---|---|
defer report(n) | The argument value evaluated now | Report the state at registration |
defer func() { report(n) }() | Access to the captured variable | Report the final state |
defer func(v int) { report(v) }(n) | An explicit argument evaluated now | Give a longer callback a specific input |
An argument copy is not necessarily an independent copy of all reachable data. Copying a pointer still reaches the same object; copying a slice still shares its backing array. Revisit Values, Pointers, and Copies before treating a captured input as immutable.
Match cleanup scope to resource lifetime
Register cleanup immediately after a successful acquisition. This keeps it close to the action it balances and covers later early returns. Effective Go: defer
However, a loop iteration is not a function return. A defer inside a long loop remains pending until its surrounding function exits. For file processing, move one file’s work into a helper so that each call finishes its cleanup before the next iteration.
This function fragment belongs in a package importing errors, io, and os:
func copyOne(dst io.Writer, path string) (err error) {
f, err := os.Open(path)
if err != nil {
return err
}
defer func() {
err = errors.Join(err, f.Close())
}()
_, err = io.Copy(dst, f)
return err
}
The helper owns the input file; the caller owns dst. It preserves a copy error and a close error if both occur. errors.Join returns nil when all its inputs are nil. errors.Join documentation
Call copyOne from the loop and handle each returned error. A plain defer f.Close() discards the close result; use that only when ignoring it is an intentional policy. When writing files, explicitly deciding how to report write, flush, and close failures is especially useful.
Likewise, a deferred unlock in a large function can hold a lock across unrelated work. A smaller helper makes the protected lifetime easier to see. Choose an explicit release when the resource must be released before the function ends, while ensuring every error path remains covered.
Defers handle ordinary returns and panic unwinding, but they are not a universal shutdown mechanism. os.Exit terminates without running them. os.Exit documentation
Experiment 3: recover defines a return boundary
Recovery stops a panic only when recover is called directly by a deferred function in the panicking goroutine. After recovery, the function owning that defer returns to its caller; execution does not restart at the failed statement. Go specification: handling panics
package main
import "fmt"
func runJob(job func()) (err error) {
defer func() {
if value := recover(); value != nil {
err = fmt.Errorf("job panicked: %v", value)
}
}()
defer fmt.Println("job cleanup")
job()
fmt.Println("job completed")
return nil
}
func main() {
err := runJob(func() { panic("broken invariant") })
fmt.Println("caller:", err)
}
Output:
job cleanup
caller: job panicked: broken invariant
The cleanup runs first because it was registered last. Recovery assigns the named error result, and the caller receives it. job completed is never printed.
This wrapper demonstrates a possible boundary for isolated jobs. It is not a complete worker design: production code needs a policy for diagnostics, stack traces, partial side effects, and whether shared state remains usable. Recovery does not roll back writes or repair an invariant.
Avoid scattering recovery throughout ordinary helpers. A boundary that recognises a specific internal panic can translate it to an error and re-panic on unexpected values, as illustrated in Effective Go’s recovery example. A boundary that intentionally contains every job panic needs a stronger isolation contract.
Two easy mistakes defeat recovery: calling it through an ordinary helper inside the deferred function, and expecting a parent’s defer to recover a child’s goroutine panic. Each independently executing goroutine needs its own deliberate boundary if recovery is part of its contract.
Choose the mechanism from the failure contract
| Situation | Starting choice | Why it matters |
|---|---|---|
| Invalid input, missing record, unavailable dependency | Return an error | The caller can reject, retry, or report an expected failure |
| A file or lock acquired for one function’s work | Defer cleanup after acquisition | Early returns stay covered |
| Resource needed for only one loop iteration | Helper function with deferred cleanup | Resources do not accumulate until the whole loop ends |
| Small delayed operation using surrounding state | Closure with explicit capture intent | Reviewers can distinguish current state from a saved input |
| Broken internal assumption | Investigate the defect; panic may expose it | Quietly reporting success would hide invalid state |
| Isolated job or request boundary | Deliberate recovery policy | Failure containment requires diagnostics and state-safety decisions |
These are design starting points. For example, recovering at a request boundary cannot retroactively replace a response already sent to the client. Decide what can still be reported before adding a generic recovery wrapper.
Exercise: change the lifetime or boundary
- In the loop program, insert
saved := vinside the second loop and capturesaved. Its callbacks now return10 20 30. - In
calculate, passnas a parameter to the deferred logging closure. Its output becomesclosure: 1because evaluation happens at registration. - Run
runJob(func() {}). Cleanup still runs,job completedappears, and the caller receives nil. - Replace the direct
recover()call with a call toindirect, defined asfunc indirect() any { return recover() }. The program now fails with an unrecovered panic. Restore direct recovery afterwards.
When reviewing a callback or defer, identify its inputs and the function that ends its lifetime. When reviewing recovery, identify which state can safely survive the failure. Those questions make the control flow reviewable without guessing compiler internals.
The next planned part, What Is Memory Allocation in Go?, starts the memory phase: storage, assignment, initialisation, new, make, and what allocation measurements actually count.
Sources
- Go specification: function literals
- Go specification: defer statements
- Go specification: handling panics
- Go blog: loop-variable semantics introduced in Go 1.22
- Go blog: defer, panic, and recover
- Effective Go: defer and recovery
- errors.Join: combining cleanup and operation errors
- os.Exit: termination without deferred calls