1. recover: what it is for and where it works
When you write an application, you almost always want it to behave predictably: either it completes successfully, or it returns a clear error. But reality is trickier: even if you diligently return error, the code may still contain programmer bugs that lead to a panic (for example, an index out of bounds on a slice). And that is where the “magic” begins (plus a bit of magical thinking): recover lets you catch a panic at the application boundary and turn it into a controlled result, so the program does not smear a stack trace across the console without any explanation to the user.
At the same time, recover is not a replacement for normal error handling; it is more like an emergency airbag: it does not make bugs “not bugs,” it makes the crash manageable.
An important framing to keep in mind: if an error is expected and you can check it in advance, that is an error. If the “impossible” happens (or you forgot to check), a panic may occur. Ideally, you do not sprinkle recover everywhere; you put it in exactly one place where the application decides: “OK, whatever happens inside, I will return a neat error to the outside.”
Where recover really works
recover is finicky, but honest: it only works in a very specific form. The rule is: recover() only makes sense inside a deferred function. During normal execution, recover() returns nil and does not catch anything.
Let’s see this in small examples.
First, a version that “does not work”:
package main
import "fmt"
func main() {
fmt.Println(recover()) // <nil>
panic("boom")
}
Here recover() is called “normally,” not via defer, so it does not save anyone.
Now a working pattern—the canonical skeleton:
package main
import "fmt"
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("recovered:", r) // recovered: boom
}
}()
panic("boom")
}
Why does this work? Because when a panic happens, Go starts “unwinding the stack,” exiting functions upward, and along the way it executes all defer calls. At that moment, the deferred function manages to call recover(), grab the “panic value,” and stop the avalanche.
One more subtle point that often confuses beginners: recover does not continue execution “from the point of the panic”. The code after the line where the panic occurred will not run. recover only turns an аварийный exit into a normal exit from the function where the defer was placed.
2. Safe main: a single protective boundary
If you embed recover into every function, the code quickly turns into a “pile of hacks,” where any error gets hidden and you then spend weeks figuring out why the application “sometimes behaves strangely.” That is why Go often uses a pattern you can call safe main: recover sits at the top level, while the main code runs with the normal contract (T, error).
The skeleton looks like this:
package main
import "fmt"
func main() {
if err := safeRun(); err != nil {
fmt.Println("app error:", err) // app error: ...
}
}
Notice: our main is intentionally as “dumb” as possible (in a good way). It just starts the work and prints the problem. No “smart decisions” in main, otherwise later you will not understand where the application logic is and where the startup logic is.
safeRun skeleton
Now we write safeRun, which is the boundary:
package main
import "fmt"
func safeRun() (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("unexpected panic: %v", r)
}
}()
return run()
}
What matters here is that safeRun returns a named err error. This allows the deferred function to assign err = ... right before returning. This technique is a standard way to “intervene” in a function’s result via defer.
And the business function run itself lives in the world of “normal people”:
package main
import "errors"
func run() error {
return errors.New("not implemented yet")
}
The idea is simple: if a panic happens inside run() or anything it calls, we do not let it kill the process “dirty,” but instead turn it into a clear error at the boundary. At the same time, we do not pretend everything is fine: the error still gets returned upward.
A small diagram to make it stick:
flowchart TD
A[main] --> B[safeRun: defer recover]
B --> C[run: business logic]
C -->|panic| D[stack unwinding]
D --> E[defer in safeRun]
E -->|recover| F[returned error]
F --> A
The point is that recover is not “try/catch,” but an “emergency shutoff switch” on the panel that sits at the entrance of the apartment, not in every room.
Panic value: panic value, and how to turn it into an error
A panic in Go is not necessarily a string. You can pass any value into panic(...), and it will arrive in recover() as any. So it is useful to know how to convert r into a clear error without losing meaning.
Let’s write a small helper. It will be useful in an application and will make safeRun cleaner.
package main
import "fmt"
func panicToError(r any) error {
if e, ok := r.(error); ok {
return fmt.Errorf("panic: %w", e)
}
return fmt.Errorf("panic: %v", r)
}
There are two cases:
| What was passed to panic(...) | What arrives in recover() | What we do |
|---|---|---|
|
|
wrap with to preserve the chain |
or |
|
format with |
Now safeRun can be made a bit cleaner:
package main
func safeRun() (err error) {
defer func() {
if r := recover(); r != nil {
err = panicToError(r)
}
}()
return run()
}
Why is it important to distinguish error from “something else”? Because if the panic carries an error, you can sometimes (at a higher level) recognize the error type with errors.Is/errors.As, if it was wrapped properly. But remember: if you have a panic with an error, it is still an авария, not a “normal branch.” It is just an авария with useful information.
3. Practical example: a mini “task list” app
So that recover does not remain a magic spell, let’s build it into a small application that we can extend later. Let it be a very modest task list that can read a command and print something.
The idea: we have a list of tasks, and the command show <i> prints the task by index. If the index is wrong, there will be a panic "index out of range". This is a classic slice bug that you have likely seen already at this stage.
run: read the command and pass control to the handler
Let’s make run() read a command and execute it:
package main
import (
"fmt"
)
func run() error {
tasks := []string{"learn_go", "drink_tea"}
var cmd string
fmt.Scan(&cmd)
return handle(cmd, tasks)
}
handle: a version without checks to see the panic
Now implement handle. We will intentionally make a version without checks to see the panic:
package main
import "fmt"
func handle(cmd string, tasks []string) error {
if cmd == "show" {
var i int
fmt.Scan(&i)
fmt.Println(tasks[i]) // with i=10 this will panic
return nil
}
fmt.Println("unknown command") // unknown command
return nil
}
Assemble safe main end-to-end
And now we assemble safe main end-to-end:
package main
import "fmt"
func main() {
if err := safeRun(); err != nil {
fmt.Println("app error:", err)
}
}
If you run it with input:
show 10
then without recover the program will crash with a panic. And with safeRun you will get controlled output like:
app error: panic: runtime error: index out of range [10] with length 2
Yes, the message is still “developer-ish,” but the main thing is that the application did not blow up the user’s console with a sudden crash, and now you can decide how exactly to present such errors. Ideally, you would show the user something friendlier and keep the details for diagnostics. But the boundary mechanism is already in place.
4. Should you print the stack on recover
When you catch a panic, there is a temptation: “Oh! Caught it! Now I will pretend nothing happened and keep going.” That is a bad scenario, because the panic could have happened when some data had already been modified, and the program state became questionable. So the typical outcome after recover is to stop the current operation and return an error upward.
Sometimes you also want a stack trace. By default, for an unhandled panic, Go prints it for you. If you recovered the panic, you “canceled” the standard panic output, and the stack will no longer be printed automatically. That can be useful, but be careful: printing the stack “in the user’s face” is a questionable idea.
If you still need it (for example, for yourself during development), you can get the stack from runtime/debug. You do not need to deeply understand the format—just know that this option exists.
package main
import (
"fmt"
"runtime/debug"
)
func safeRun() (err error) {
defer func() {
if r := recover(); r != nil {
fmt.Printf("panic stack:\n%s\n", debug.Stack())
err = panicToError(r)
}
}()
return run()
}
There is an important philosophical detail here: recover must not turn into a “vacuum cleaner that sucks up all problems.” If you caught a panic, you made the crash predictable, but then you still have to fix the root cause. Otherwise it is like putting a band-aid on a fire alarm because it “beeps too loud.”
5. Common mistakes when using recover
Mistake #1: calling recover() outside a deferred function and expecting a miracle.
recover is not a “universal trap.” In a normal spot it returns nil and catches nothing, because catching is only possible during panic unwinding, when defer calls are executed.
Mistake #2: putting recover in every function “so it won’t crash.”
This turns bugs into hidden bugs: the program seemingly does not crash, but it may continue running in a questionable state. It is much healthier to keep one protective boundary (for example, safeRun) and fix the cause of the panic in the logic.
Mistake #3: catching a panic and staying silent.
If after recover you return nothing and report nothing, you create a “vanishing error” effect: the app behaves strangely, but it is impossible to understand why. After catching a panic there should be an explicit action: return an error, print a clear message, abort the operation.
Mistake #4: trying to continue after recover as if nothing happened.
recover does not send you “to the line after the panic.” It only allows the function to finish normally. So the correct strategy is to end the current scenario, return an error upward, and not pretend that everything is fine.
Mistake #5: using panic/recover instead of a normal error for expected situations.
If you can check a condition in advance (for example, an index must be within range, a divisor is not zero, a key exists in a map), then this is a normal branch of logic, and it belongs in if and return error, not in panic. Panics are for violated assumptions and programmer errors, and recover is a rare страховка at the application boundary.
GO TO FULL VERSION