Deep Engineering
Intermediate·Published·35 MIN

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.

Where to start
Before this lesson it is enough to understand
  • 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.
You do not need to know in advance
  • stack unwinding, named results, what panic and recover actually do — all explained on the way;
  • open-coded defer, *runtime.PanicNilError, a runtime fatal error, and the nanoseconds from the measurements.

What is really being asked

The ladder is almost always this one:

  1. "When does a deferred call run?" — the warm-up; everyone answers.
  2. "And when are its arguments evaluated?" (with code) — half drop out here.
  3. "In what order?" and right after it "what happens with defer in a loop?"
  4. "Can a deferred function change the return value?" — the named-results question.
  5. "What will recover not catch?" — testing whether you know about "directly inside the deferred function" and about another goroutine.
  6. "Is defer expensive?" — 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:

language contractLanguage guarantees: the order of the moments is set by the specification. Cost does not appear until the «Deeper» section.

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:

  1. Registration — when the line with defer executes.
  2. Argument evaluation — at that same moment, at registration.
  3. 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.

The Go specification — Defer statements

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

The Go specification — Defer statements

Mechanism 2: defer in a loop is a question of timing, not cost

The classic "find the bug", and the code is usually this:

GO
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:

GO
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.

Effective Go — Defer, Panic, Recover

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.

GO
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

The Go specification — Defer statements

Where this is used in practice. In exactly two places, and both are worth naming:

GO
// 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.

The builtin package — the recover function

Verified by running it:

formcaught
recover() directly in the deferred functiontrue
recover() moved into a helperfalse

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
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:

situationhow to report
the file did not open, the request failed, the input is wrongan error value
an invariant the author believed impossible was brokena panic
bad arguments during package initialisationa 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

measured observationbench/godefer, go1.24.7 linux/amd64, two cores. The numbers were taken on one machine and on empty functions: this is an upper bound on the difference.

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:

timeallocations
one defer in a function4.68 ns0
defer in a loop, 8 iterations16.8 ns per iteration0
defer in a loop, 64 iterations22.7 ns per iteration0

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.

implementation detail · Go 1.24Open coding, the list of deferred calls and where the record for a call is placed are how the current compiler and runtime work, not a promise of the language. The specification fixes only the timing and the order; what that expands into has changed before (open coding arrived in Go 1.14) and may change again.

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 hand24.44 ns
ratio1.2

A panic is another matter:

time
return err2.24 ns
defer + recover, no panic4.30 ns
defer + recover, panic273.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

Next they ask

Will a defer run if the function panics?

Short answer

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.

Next they ask

What does recover return if there was no panic?

Short answer

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.

Next they ask

Should you recover from a panic in an HTTP handler?

Short answer

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.

Next they ask

How do you check the error from Close properly?

Short answer

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:

GO
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.

Next they ask

How many deferred calls can pile up?

Short answer

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.

Next they ask

How does panic differ from exceptions in other languages?

Short answer

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

Claim

defer postpones the whole expression

Actually

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) }().

Claim

defer in a loop is merely slow

Actually

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.

Claim

defer in a loop allocates on the heap

Actually

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.

Claim

a deferred function cannot affect the return value

Actually

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.

Claim

recover can be moved into a shared helper method

Actually

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.

Claim

a recover in main will catch a panic from any goroutine

Actually

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.

Claim

recover saves you from any failure

Actually

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.

Claim

defer is too expensive for hot code

Actually

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

The arguments of a deferred call, the order of three defers, a named and an unnamed result. What does this code print?
	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

A critical section with identical work inside. How many times dearer is defer mu.Unlock() than mu.Unlock() by hand?
times

Knowledge check

Question 1 of 6

i := 0; defer fmt.Println(i); i = 42. What does the deferred call print?

Sources & further reading

4 SOURCES

  1. 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
  2. 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
  3. 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
  4. 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