defer, panic and recover in Go: three moments instead of one, and a recover that silently does nothing
The interview climbs a ladder: when a deferred call runs — when its arguments are evaluated — in what order — why a deferred function can change the result — what recover catches and what it does not — and what all of it costs. Measured: an open-coded defer at 4.68 ns against 16.8 ns per iteration in a loop, and a panic against an error return by a factor of 122.
Full technical treatment
TL;DR
defer is not "run at the end" but three separate moments: registration,
argument evaluation and execution. A deferred call is recorded now and runs when
the function exits; its arguments are evaluated when it is recorded; several
deferred calls run in reverse order. A panic, in turn, unwinds the stack — and
it can be caught only from a deferred call.
Hence everything that behaves differently from how the code reads.
i := 0; defer f(i); i = 42 prints 0; the cure is a wrapper with no
arguments, defer func() { f(i) }(). defer in a loop is about timing, not
cost: it fires when the function returns, not the iteration. A deferred
function can change the result — but only a named one: measured, the named
result gives 2, the unnamed one 1. And recover works only if called
directly inside the deferred function: move it into a helper "for tidiness" and
the run gives false against true, with no warning anywhere.
Beyond that come the numbers, the versions and the boundaries. One defer
in a function is 4.68 ns (zero allocations); in a loop, 16.8 ns per iteration;
defer mu.Unlock() is 1.2 times dearer than doing it by hand; a panic
against an error return, 122 times. That last ratio is an upper bound: it
was taken on go1.24.7 on empty functions, where nothing happens besides the form
of the return itself. Expanding a defer as open code is a property of the
compiler since Go 1.14 rather than a guarantee of the language; and since Go
1.21 panic(nil) becomes a *runtime.PanicNilError.
- a function does something and eventually hands control back to whoever called it;
- a resource comes with paired operations: a file is opened and closed, a mutex is acquired and released;
- a program can abort if something goes badly wrong.
- stack unwinding, named results, what
panicandrecoveractually do — all explained on the way; - open-coded
defer,*runtime.PanicNilError, a runtimefatal error, and the nanoseconds from the measurements.
What is really being asked
The ladder is almost always this one:
- "When does a deferred call run?" — the warm-up; everyone answers.
- "And when are its arguments evaluated?" (with code) — half drop out here.
- "In what order?" and right after it "what happens with
deferin a loop?" - "Can a deferred function change the return value?" — the named-results question.
- "What will
recovernot catch?" — testing whether you know about "directly inside the deferred function" and about another goroutine. - "Is
deferexpensive?" — a question about honesty: without a number both extremes are equally bad.
The lesson climbs that ladder. Its spine is one sentence: a deferred call has three moments, not one.
Base: three rules for a deferred call, and one for a panic
Before taking the mechanism apart it is worth naming, in ordinary words, what it
is made of. defer has three rules, and all three are about time.
First: a deferred call is recorded now and runs when the function exits. The
line defer f() calls nothing by itself. It says: "when this function is on its
way out, call f()." On its way out means reaching a return by any route,
including an error. Hence the point of it: the close sits next to the open and
cannot be forgotten, however many returns the function has.
Second: the arguments of a deferred call are evaluated when it is recorded,
not when it runs. defer f(i) remembers not the variable i but its value —
whatever it held on that line. Change i later and the deferred call will never
know. This is the one place in the topic where the code reads differently from
how it runs, and it is where people get it wrong most often.
Third: several deferred calls run in reverse order. Record three and the third, the second and the first run, in that order. Which is exactly what paired operations want: whatever was acquired last is released first.
Now the panic. A panic does not stop the program on the spot — it unwinds the
stack: it travels up the calls from the point of failure and, at every level
on the way, runs that level's deferred calls. That is why defer is reliable
under a panic too: the file still gets closed, the mutex still gets released.
And from the same place comes the limit half the interview questions grow out of: a panic can be caught only from a deferred call. There is nowhere else — during the unwinding only deferred calls run; the ordinary code of the frame is already behind by then.
That is already enough to answer the basic interview question. What follows is
about where those three rules produce an unexpected result: why a defer in a
loop keeps every file open at once, why a deferred function can change the
return value, and why a recovery moved into a helper "for tidiness" silently
stops working.
Mechanism 1: one timeline for three mechanisms
defer, panic and recover are usually explained separately, and then they
have to be memorised. In fact they are three points on one timeline — the
life of a function call:
The lesson then walks that timeline top to bottom. Everything that looks strange in the topic is a consequence of where on it a given point sits.
Start with the three moments of defer itself:
- Registration — when the line with
deferexecutes. - Argument evaluation — at that same moment, at registration.
- Execution — immediately before the function returns.
The second point is the whole difference between "I know the rule" and "I know the mechanism". The specification states it outright:
Each time a "defer" statement executes, the function value and parameters to the
call are evaluated as usual and saved anew but the actual function is not
invoked.
Switch scenes and steps — each one shows which of the three moments has arrived:
Three conclusions follow, and each is asked about separately.
The arguments are already captured. defer fmt.Println(i) prints whatever
i held on the defer line. Want a late read? Wrap it: defer func() { fmt.Println(i) }(). Inside the literal, i is read when the call runs.
A method receiver is an argument too. defer mu.Unlock() captures mu
straight away, and if the variable is reassigned, the deferred call goes to the
old object.
The order is reversed because it is a stack. Releasing runs opposite to acquiring: the nested thing closes before the enclosing one.
deferred functions are invoked immediately before the surrounding function
returns, in the reverse order they were deferred
Mechanism 2: defer in a loop is a question of timing, not cost
The classic "find the bug", and the code is usually this:
func process(names []string) error {
for _, name := range names {
f, err := os.Open(name)
if err != nil {
return err
}
defer f.Close() // TRAP
// …work with f…
}
return nil
}The bug is not that this is slow. The bug is that f.Close() runs when
process returns, not when the iteration ends. With a thousand files, all
thousand are open at once — and on some of them the program hits the descriptor
limit.
The cure is to lift the loop body into a function:
for _, name := range names {
if err := handle(name); err != nil { // defer lives inside handle
return err
}
}Or, if you would rather not extract anything, close explicitly instead of
deferring. That second option is worse: it brings back exactly the problem
defer exists for.
Deferring a call to a function such as Close has two advantages. First, it
guarantees that you will never forget to close the file … Second, it means that
the close sits near the open, which is much clearer than placing it at the end of
the function.
Mechanism 3: only a named result can be changed
The question is phrased as "can defer affect the return value", and the right
answer is "yes, if the result is named". The mechanism: return x is not one
action but two. First x is assigned to the result, then the deferred calls
run, and only then does control leave for the caller.
func named() (result int) {
defer func() { result *= 2 }()
return 1 // returns 2
}
func anon() int {
result := 1
defer func() { result *= 2 }()
return result // returns 1
}The difference is not stylistic. In the first case the deferred function closes over the result itself; in the second, over a local variable whose value has already been copied into the result.
if the deferred function is a function literal and the surrounding function has
named result parameters that are in scope within the literal, the deferred
function may access and modify the result parameters before they are returned
Where this is used in practice. In exactly two places, and both are worth naming:
// 1. Turn a panic into an error at a package boundary.
func Parse(b []byte) (v Value, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("parse: %v", r)
}
}()
return parseFast(b), nil
}
// 2. Wrap an error with context without touching every return.
func load(name string) (err error) {
defer func() {
if err != nil {
err = fmt.Errorf("load %s: %w", name, err)
}
}()
// …a dozen return err…
}Both work only with a named result — and both stop working silently if the name is removed.
Mechanism 4: a panic is the stack unwinding
Before working through what recover does not catch, it is worth saying what
happens during a panic — otherwise every limitation looks arbitrary.
panic(v) does not stop the program on the spot. It starts the stack
unwinding, and on every frame along the way up the same thing happens:
Three things follow directly, and all three get asked.
Deferred calls do run during a panic. This is not an "emergency exit" — it is
the same moment 3 from the timeline in mechanism 1. Which is exactly why
defer f.Close() is reliable: the file is closed on a panic too.
Recovery is possible only on a frame that has not been unwound yet. The
unwinding goes bottom to top, so recover in the decode frame works, while in
a frame already passed it does not. By that point the frames below the panic no
longer exist.
If nobody recovers, the unwinding reaches the top of the goroutine — and the process exits with a trace. Not "crashes at random": it reaches the end along a perfectly definite path.
What recover does not catch
There are three answers here, and each is asked about separately.
First, and most common. recover works only when called directly inside the
deferred function. Not inside a function called by it. The documentation says
so in parentheses, and those parentheses are the most expensive part of the
topic:
Executing a call to recover inside a deferred function (but not any function
called by it) stops the panicking sequence.
Verified by running it:
| form | caught |
|---|---|
recover() directly in the deferred function | true |
recover() moved into a helper | false |
The second row is that very refactor: "let's move the recovery into a shared helper". It compiles, passes review and turns the recovery off.
Second — a panic in another goroutine. Each goroutine has its own stack of
deferred calls, and it cannot be caught from outside: a recover in the calling
code sees nothing and the process dies. The specification speaks of a "panicking
goroutine", and the word is precise. Hence a practical rule: every
goroutine you launch needs its own recovery if its failure must not kill the
process.
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("worker: %v", r)
}
}()
work()
}()Third — a runtime fatal error. A concurrent map write, all goroutines
deadlocked, memory exhausted — these are not panics. The stack is not unwound,
deferred calls do not run, recover is never reached.
And separately, panic(nil): since Go 1.21 it becomes a
*runtime.PanicNilError. Before that, recover() returned nil, the
if r := recover(); r != nil check did not fire, and the function carried on
after the panic — silently and with a wrong result.
Mechanism 5: a panic is not a way to return an error
A separate question, asked towards the end, that separates those who have written Go in production.
An error is an expected outcome, part of the function's contract: the file is missing, the network is down, the input is malformed. Those are returned as values, and the caller decides what to do.
A panic is a broken invariant — a state you cannot continue from and that
there is no point reporting to the caller: an index out of range, a nil
dereference, an impossible switch branch. The standard library panics in
exactly such places.
Hence a practical boundary, and a checkable one:
| situation | how to report |
|---|---|
| the file did not open, the request failed, the input is wrong | an error value |
| an invariant the author believed impossible was broken | a panic |
| bad arguments during package initialisation | a panic is acceptable (regexp.MustCompile) |
"there are too many if err != nil here" | error; a panic does not cure this |
The last row is the most common design mistake. A panic used to avoid checks makes control flow invisible: the caller cannot tell from the signature that the function may not return.
And one rule about boundaries. A panic must not cross a package boundary: if
a panic is used internally for convenience, it is recovered on the way out and
turned into an error. That is how parsing in encoding/json is built, for
instance.
Deeper: what it costs
The numbers come from a run of bench/godefer/internals.go, in interleaved
rounds.
defer has two different prices, and in the source they are the same word:
| time | allocations | |
|---|---|---|
one defer in a function | 4.68 ns | 0 |
defer in a loop, 8 iterations | 16.8 ns per iteration | 0 |
defer in a loop, 64 iterations | 22.7 ns per iteration | 0 |
The first row is the "open-coded" defer: since Go 1.14 the compiler expands it
into ordinary code at the function's exit. A loop does not allow that — the
number of deferred calls is not known in advance, and each one must be
registered in a list.
An important correction to the received wisdom. There are no heap
allocations in any of the cases — the record for a deferred call goes on the
stack. "A defer in a loop allocates" is repeated often, and the measurement
does not support it.
On the most common use the difference is smaller still:
| time | |
|---|---|
defer mu.Unlock() | 28.48 ns |
mu.Unlock() by hand | 24.44 ns |
| ratio | 1.2 |
A panic is another matter:
| time | |
|---|---|
return err | 2.24 ns |
defer + recover, no panic | 4.30 ns |
defer + recover, panic | 273.04 ns |
| panic against a return | ×122 |
The third row matters more than the fourth: the recovering form costs more even when no panic happened. Every call pays, not only the failing one.
And the fourth row needs its boundary named, or it turns into a law. 122 is the ratio between two empty functions on one machine and one version of Go: nothing happens in them besides the form of the return itself, so the ratio here is an upper bound rather than a typical figure. Put anything useful into both functions and it shrinks, because the share of return machinery in the total time shrinks.
So the rule is not written from the number. Not "a panic is a hundred times
dearer than an error", but: a panic is not how an expected failure is
returned — for the reason mechanism 5 gives, which does not depend on the
measurement at all. The ratio is good for exactly one thing: knowing the cost is
not a matter of single percent, and not trying to win anything by choosing
between an error and a panic where meaning already decides the choice.
How to answer in an interview
Short answer: a deferred call has three moments, not one. Registration and
argument evaluation happen at the defer, execution when the function exits;
and if there are several deferred calls, they run in reverse order. A panic
unwinds the stack, running each frame's deferred calls on the way, and it can be
caught only by a recover called directly inside a deferred function.
That is enough for a correct answer. What follows is what you add when the interviewer digs.
If the interviewer digs deeper
To "when does it run" answer with three moments. "Registration and argument
evaluation at the defer, execution before the return." The second point
immediately marks out someone who knows the mechanism.
On the loop, talk about timing rather than speed. "It fires when the function returns, not the iteration; with a thousand files you get a thousand open descriptors." "It is slow" is not the expected answer.
On changing the result, name the two actions of return. "First the
assignment to the result, then the deferred calls; that is why a named result
can be changed and an unnamed one cannot."
On recover, quote the parenthesis. "Only directly in the deferred function
— not in one called by it." And add the two things it does not catch: another
goroutine and a fatal error.
On cost, give two numbers and separate them. "An open-coded defer is single
nanoseconds and zero allocations; in a loop it is tens per iteration. A panic
against an error return is two orders of magnitude." One number without the other
turns a right measurement into a wrong rule.
Next they ask
Will a defer run if the function panics?
Yes — that is the point. A panic unwinds the stack, and at each level that
level's deferred calls run. That is exactly why defer mu.Unlock() is correct
under a panic too: the mutex will be released.
They do not run in two cases: os.Exit (it terminates the process without
unwinding) and a runtime fatal error. Hence the practical note: an os.Exit
in the middle of a function with deferred calls silently loses every cleanup.
What does recover return if there was no panic?
nil — and that is the legitimate way to check. Which is why the idiom is
written if r := recover(); r != nil rather than just recover().
The trap used to be that before Go 1.21 panic(nil) also gave nil, so that
check missed a real panic. Since 1.21 the value is replaced with
*runtime.PanicNilError and the check is correct again. The old behaviour can be
restored with GODEBUG=panicnil=1 — worth knowing about for somebody else's
legacy.
Should you recover from a panic in an HTTP handler?
net/http already does: the server wraps every handler and, on a panic, closes
the connection and logs the stack. The process survives.
Your own recovery makes sense to return a 500 instead of a broken connection
and to record the panic in your metrics. But recovering and continuing the
work is not on: a panic means a broken invariant, and the state after it is
undefined.
How do you check the error from Close properly?
defer f.Close() discards it — which is fine for reading and not fine for
writing: a close error on a file opened for writing can mean the data never
reached the disk.
The correct form uses a named result:
func write(name string) (err error) {
f, err := os.Create(name)
if err != nil {
return err
}
defer func() {
if cerr := f.Close(); cerr != nil && err == nil {
err = cerr
}
}()
// …writing…
}The err == nil condition is mandatory: without it the close error overwrites
the real cause of the failure.
How many deferred calls can pile up?
There is no hard limit; memory is the constraint. But the question is usually about something else: a function that piles up deferred calls in a loop and therefore holds resources.
A practical guide: if the number of defers in a function depends on the input
data, that is nearly always a design mistake rather than an optimisation one. The
loop body should become a function, and then the defer inside it is open-coded
again and fires on time.
How does panic differ from exceptions in other languages?
Mechanically — barely at all: stack unwinding, handlers, the ability to catch. What differs is the convention, and it is stricter.
"The convention in the Go libraries is that even when a package uses panic internally, its external API still presents explicit error return values." So a panic is an internal mechanism and errors are what come out. "A panic is like an exception, just use it" is not the expected answer.
Common misconceptions
defer postpones the whole expression
Only the call is postponed. The arguments are evaluated at the defer and saved: i := 0; defer f(i); i = 42 passes zero. A late read comes from a wrapper with no arguments — defer func() { f(i) }().
defer in a loop is merely slow
It is not about speed but about timing: the deferred call fires when the function returns, not the iteration. A thousand files opened in a loop stay open all at once. The cure is lifting the loop body into a function.
defer in a loop allocates on the heap
The measurement does not support it: zero allocations for both the open-coded defer and the one in a loop — the record goes on the stack. What costs is registration in the list of deferred calls: 4.68 ns against 16.8 ns per iteration.
a deferred function cannot affect the return value
It can — if the result is named. return x is two actions: assign to the result, then run the deferred calls. Measured: the named result gives 2, the unnamed one 1. The whole panic-to-error idiom rests on this.
recover can be moved into a shared helper method
It cannot, and it breaks silently. The documentation: a recover call works inside a deferred function, "but not any function called by it". The run confirms it: the direct call catches, the one moved into a helper does not.
a recover in main will catch a panic from any goroutine
It will not: each goroutine has its own stack of deferred calls, and the specification speaks of a "panicking goroutine". The recovery must live inside the goroutine itself, or its panic kills the process.
recover saves you from any failure
A runtime fatal error — a concurrent map write, all goroutines deadlocked — is not a panic: the stack is not unwound and deferred calls do not run. And before Go 1.21 a panic(nil) slipped past the r != nil check, so the function carried on after panicking.
defer is too expensive for hot code
Which defer? One defer in a function is 4.68 ns and zero allocations; defer mu.Unlock() is only 1.2 times dearer than doing it by hand. What is dear is the loop form — and even there the point is not the price.
Practice
Two problems. Answer first, then check against the real output: in both, the correct answer is taken from a run of the script rather than assigned.
Practice · predict the output
i := 0
defer fmt.Println(i)
i = 42
fmt.Println(i)
for n := 1; n <= 3; n++ {
defer fmt.Println(n)
}
func namedResult() (result int) {
defer func() { result *= 2 }()
return 1
}
func anonResult() int {
result := 1
defer func() { result *= 2 }()
return result
}Practice · estimate
Knowledge check
i := 0; defer fmt.Println(i); i = 42. What does the deferred call print?
This is neither a retelling nor a separate text: everything below is taken from the article itself — its own summary, the section headings, the “actually” column and the version table. Which is why these theses cannot drift from the article.
The gist
deferis not "run at the end" but three separate moments: registration, argument evaluation and execution. A deferred call is recorded now and runs when the function exits; its arguments are evaluated when it is recorded; several deferred calls run in reverse order. A panic, in turn, unwinds the stack — and it can be caught only from a deferred call.- Hence everything that behaves differently from how the code reads.
i := 0; defer f(i); i = 42prints 0; the cure is a wrapper with no arguments,defer func() { f(i) }().deferin a loop is about timing, not cost: it fires when the function returns, not the iteration. A deferred function can change the result — but only a named one: measured, the named result gives 2, the unnamed one 1. Andrecoverworks only if called directly inside the deferred function: move it into a helper "for tidiness" and the run gives false against true, with no warning anywhere. - Beyond that come the numbers, the versions and the boundaries. One
deferin a function is 4.68 ns (zero allocations); in a loop, 16.8 ns per iteration;defer mu.Unlock()is 1.2 times dearer than doing it by hand; a panic against an error return, 122 times. That last ratio is an upper bound: it was taken on go1.24.7 on empty functions, where nothing happens besides the form of the return itself. Expanding adeferas open code is a property of the compiler since Go 1.14 rather than a guarantee of the language; and since Go 1.21panic(nil)becomes a*runtime.PanicNilError.
In fact
- Only the call is postponed. The arguments are evaluated at the
deferand saved:i := 0; defer f(i); i = 42passes zero. A late read comes from a wrapper with no arguments —defer func() { f(i) }(). - It is not about speed but about timing: the deferred call fires when the function returns, not the iteration. A thousand files opened in a loop stay open all at once. The cure is lifting the loop body into a function.
- The measurement does not support it: zero allocations for both the open-coded defer and the one in a loop — the record goes on the stack. What costs is registration in the list of deferred calls: 4.68 ns against 16.8 ns per iteration.
- It can — if the result is named.
return xis two actions: assign to the result, then run the deferred calls. Measured: the named result gives 2, the unnamed one 1. The whole panic-to-error idiom rests on this. - It cannot, and it breaks silently. The documentation: a recover call works inside a deferred function, "but not any function called by it". The run confirms it: the direct call catches, the one moved into a helper does not.
- It will not: each goroutine has its own stack of deferred calls, and the specification speaks of a "panicking goroutine". The recovery must live inside the goroutine itself, or its panic kills the process.
- A runtime
fatal error— a concurrent map write, all goroutines deadlocked — is not a panic: the stack is not unwound and deferred calls do not run. And before Go 1.21 apanic(nil)slipped past ther != nilcheck, so the function carried on after panicking. - Which defer? One
deferin a function is 4.68 ns and zero allocations;defer mu.Unlock()is only 1.2 times dearer than doing it by hand. What is dear is the loop form — and even there the point is not the price.
What is covered
- What is really being asked
- Base: three rules for a deferred call, and one for a panic
- Mechanism 1: one timeline for three mechanisms
- Mechanism 2: defer in a loop is a question of timing, not cost
- Mechanism 3: only a named result can be changed
- Mechanism 4: a panic is the stack unwinding
- Mechanism 5: a panic is not a way to return an error
- Deeper: what it costs
- How to answer in an interview
- Next they ask
- Common misconceptions
- Practice
- Knowledge check
Sources & further reading
4 SOURCES
- The Go specification — Defer statements, Handling panicsOfficial documentation. Three rules the whole topic derives from. On arguments: «Each time a "defer" statement executes, the function value and parameters to the call are evaluated as usual and saved anew but the actual function is not invoked». On order and timing: «deferred functions are invoked immediately before the surrounding function returns, in the reverse order they were deferred». And on results: «if the deferred function is a function literal and the surrounding function has named result parameters that are in scope within the literal, the deferred function may access and modify the result parameters before they are returned». On recover: «The recover function allows a program to manage behavior of a panicking goroutine» — and the word «goroutine» there is the load-bearing one.https://go.dev/ref/spec#Defer_statements
- The builtin package — the recover functionOfficial documentation. The condition people break most often is written in the function's own documentation: «The recover function allows a program to manage behavior of a panicking goroutine. Executing a call to recover inside a deferred function (but not any function called by it) stops the panicking sequence». The parenthesis in that sentence is the most expensive part of the topic. And on the no-panic case: «if the goroutine is not panicking or recover was not called directly by a deferred function, recover returns nil».https://pkg.go.dev/builtin#recover
- Effective Go — Defer, Panic, RecoverOfficial documentation. On what defer is for and why the timing is what it is: «Deferring a call to a function such as Close has two advantages. First, it guarantees that you will never forget to close the file … Second, it means that the close sits near the open, which is much clearer than placing it at the end of the function». And the boundary for panics: «The convention in the Go libraries is that even when a package uses panic internally, its external API still presents explicit error return values».https://go.dev/doc/effective_go#defer
- Go 1.21 release notes — panic(nil)Official documentation. The change people have been asking about since 2023: «In Go 1.21, panic(nil) now causes a run-time panic of type *runtime.PanicNilError». Before that, recover returned nil, the `if r := recover(); r != nil` check silently missed such a panic, and the program carried on after panicking.https://go.dev/doc/go1.21