1. Introduction
If you’re just starting out, you really want to make the program “never crash.” It’s a very human desire: a crash feels like failure, and recover looks like a superpower. The problem is that sometimes a crash is an honest signal: “there’s a bug in the code.” And if we start “catching everything” everywhere, we turn bugs into quiet, slippery, expensive problems.
Imagine that the breakers in your home keep tripping. There are two approaches: replace the breakers with “infinite” ones (so they never trip), or figure out why your kettle is shorting. recover is not an “infinite breaker” for the whole apartment. It’s a careful shield at the entrance: if something goes seriously wrong inside, we don’t let that “explosion” ruin the user experience, but we also don’t pretend everything is fine.
In Go, there’s an important cultural norm: even if a package uses panic internally, its external API usually still returns normal error values (that is, “outside — like normal people, inside — however it goes”). Today we’re fixing the boundaries so we don’t confuse “however it goes” with “how it should be.”
Application boundary: where to put recover
When people say “application boundary,” it sounds philosophical. In practice it’s very down-to-earth: the place where your code meets the outside world and where you must turn internal problems into a clear result. In a simple learning app, this is most often main() — the single entry point.
Why exactly there? Because recover must not make business decisions. It must not decide “add the task or not,” “is the input valid,” “what to do if not found.” Those are normal logic branches, and we express them via error and if err != nil { ... }. recover should do something else: “if the impossible happens — don’t embarrass ourselves in front of the user with a stack trace; instead, terminate the operation in a controlled way.”
It’s useful to keep a simple layer picture in mind:
flowchart TD
U[User / outside world] --> M["Application boundary: main()"]
M --> A[Application code: functions, logic, models]
A --> L[Libraries / stdlib]
L -->|error| A
A -->|error| M
M -->|message to user| U
L -->|panic| A
A -->|panic stack unwinding| M
M -->|recover + controlled fail| U
The point of the diagram is that error is the expected route (like a normal road), and panic is an emergency evacuation from a building. You shouldn’t trigger an evacuation for every “didn’t like the input.” Evacuation is for when something is on fire.
2. The rule: recover only at the boundary
A phrasing that’s useful to literally say out loud when your hand reaches for recover inside logic:
recover must be placed where you are ready to end the operation, not “continue as if nothing happened.”
If you do recover in the middle of business code, you usually cannot guarantee that the state remained valid. A panic could happen in the middle of modifying data. Or in the middle of a calculation. Or in the middle of a “chain” of several steps. Yes, in our learning examples it might be 3 variables and one slice, but the principle is the same as in production.
A minimal “protective pattern” in Go style looks like this: we move the real work into run() (or appRun()), and in safeRun() we set a defer panic interceptor and return an error.
package main
import (
"fmt"
)
func safeRun() (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("unexpected panic: %v", r)
}
}()
return run()
}
func main() {
if err := safeRun(); err != nil {
fmt.Println("error:", err) // error: unexpected panic: ...
}
}
Notice an important detail: after recover we don’t try to “finish the work.” We say: “the operation ended abnormally; here is the error.” This matches the mechanics: recover returns control, but code after the panic site will not be executed, and trying to keep going with a questionable state is usually a bad idea.
3. Expected problems: use error
At this point, beginners often experience a “concept substitution.” It can seem like you can write “super-universal” code: panic everywhere, and catch at the top. Formally you can, but then you lose the meaning of errors as part of the contract.
There’s a very simple sanity check.
- If the problem can be predicted and checked with a condition, it’s a candidate for error.
- If the problem means “the code reached an impossible state,” it’s a candidate for panic.
An example of an expected problem is division by zero if the divisor comes from the user. That’s not an “impossible state,” it’s normal bad input, and it should be handled via error:
package main
import (
"errors"
"fmt"
)
func divide(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
func main() {
_, err := divide(10, 0)
fmt.Println("err:", err) // err: division by zero
}
And this matters: if you use panic in such a situation, it’s like you’re saying: “the user did the impossible.” But the user... the user can do anything. Especially break our expectations.
5. Why recover in business logic is an anti-pattern
Now we’ll intentionally look at “bad” code. Not because you must write it this way, but because it’s useful to see it once and say: “okay, I won’t do that.”
Suppose we’re writing a small app called “TaskBox” — a task list. We store tasks in a slice, and someone tries to update a task by index. An index error is a typical runtime panic (out of bounds).
Some people try to “fix” it like this:
package main
import "fmt"
func updateAt(tasks []string, idx int, title string) {
defer func() { _ = recover() }() // "just don't crash"
tasks[idx] = title
fmt.Println("updated") // might not run
}
What’s wrong here, even if the program “didn’t crash.”
First, we silently swallowed the problem. The caller doesn’t know the update didn’t happen. It will keep working as if the task was updated. That’s no longer just an “error,” it’s a lie.
Second, we broke the function’s contract. From the outside, updateAt looks like a function that updates a task. But in reality sometimes it “does nothing.” And without any signal. That’s worse than an honest panic.
Third, you don’t control where exactly the panic happened. Today it’s the index, tomorrow it’s another bug. You start hiding bugs that are very useful to find early.
Hence a practical rule: recover in business code almost always turns into a “hidden try/catch” that simply masks problems. And our goal is predictability.
6. The compromise: panic inside, error outside
There’s a subtle point: sometimes inside a function it’s genuinely convenient to use panic as a “fast exit” from deep nesting so you don’t have to thread err through ten levels, and at the top of that same component carefully convert everything into an error. In Go materials, the idea is often discussed that even if panic is used internally, the external contract remains error-based (via error).
But the conditions matter.
This approach works when you control the entire “capsule”: you know for sure that the panic will be caught in one place, and you don’t let it leak outward to the user. And second: you don’t use it for expected input errors, because then you’re just replacing if err != nil with “explode and catch,” which hurts readability.
A mini example in the style of “inside — must, outside — error.” We intentionally create mustParseInt, which panics, and above it a safe layer that returns an error:
package main
import (
"fmt"
"strconv"
)
func mustParseInt(s string) int {
n, err := strconv.Atoi(s)
if err != nil {
panic(err)
}
return n
}
func parseInt(s string) (n int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("parse int %q: %v", s, r)
}
}()
return mustParseInt(s), nil
}
This technique is acceptable as an internal implementation detail, but remember: it’s not a replacement for a normal error contract. It’s a way not to smear handling across low levels, if you intentionally close it at the top.
7. Checklist: when you must not use recover
This section is intentionally short and practical. It’s not “exception theory,” it’s protection against the typical mistake: “let’s put recover everywhere and everything will be stable.”
Below is a reminder table. It’s handy to keep in mind when you decide “catch or not catch.”
| Situation | Why you must not use recover | What to do instead |
|---|---|---|
| Bad user input (not a number, empty string, invalid format) | This is an expected branch. “Catching panic” here is like putting out a candle with a fire hose | Return (T, error) and handle if err != nil |
| “Not found” (key missing in a map, item missing in a list) | This is not a disaster, it’s a search result. Panic hides the business meaning | Return (T, bool) or (T, error) and provide a clear not found |
| Data validation (checking constraints, ranges, required fields) | Validation must be explicit; otherwise the code becomes unpredictable | Explicit checks + error with a normal message |
| “We want to keep working after a failure” | After a panic, the state may be partially modified; continuing is often dangerous | End the operation: return an error, roll back changes (if you designed for it) |
| “Let’s hide a bug so it won’t crash in prod” | This turns a bug into silent data corruption and a long hunt in the dark | Catch panic only at the boundary, log/record it, fix the root cause |
| recover in a library function “just in case” | A library should not decide UX and crash policy for the application | Let the panic rise to the application boundary, or return an error |
Notice how this echoes the main idea: recover is appropriate at the top level of a handler so we don’t show the user scary internals, while keeping the details for the developer.
8. TaskBox example: put a “shield at the entrance”
Now we’ll build a small but important architectural habit. We won’t complicate “TaskBox” with files, networks, and so on — functions and errors are enough for us. The idea is: main() is the entry point, so we put a “shield” there, and all logic works with regular error values.
Let’s say we have a command add <title>, and we want to add a task. For simplicity, tasks are strings.
Logic layer: no recover, only error.
package main
import "errors"
func addTask(tasks []string, title string) ([]string, error) {
if title == "" {
return tasks, errors.New("title must not be empty")
}
return append(tasks, title), nil
}
Now the runner layer, where we do a “safe entry”:
package main
import "fmt"
func safeRun() (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("internal error: %v", r)
}
}()
return run()
}
And finally main() prints the result to the user:
package main
import "fmt"
func main() {
if err := safeRun(); err != nil {
fmt.Println(err) // internal error: ...
}
}
Why this is a good style specifically for a beginner: you keep the familiar world model “errors are values” and you don’t turn the program into a “panic machine.” At the same time you gain insurance: if somewhere you forgot to check a slice boundary, accidentally got nil where you didn’t expect it, or did some other “oops,” the app won’t fall apart in an ugly way.
How not to do it: recover as try/catch
Sometimes you want to make a universal wrapper around every function: like, “let everything be safe.” But that almost always makes maintenance worse.
Imagine you have an “index out of range” bug. If it surfaces as a panic, you see the stack trace and quickly find the location. If you swallowed it somewhere low, you see “something went wrong” without context. And you start debugging like an archaeologist: with a brush, grain by grain.
Even more important: if you “continued” after recover (or pretended you did), you may end up with corrupted state. A scary fact of development: corrupted state can look “fine” for a minute, an hour, a day — and then blow up somewhere completely different.
So a good rule sounds boring, but saves your nerves: recover is the point where we stop the current operation and return a controlled result.
9. Common mistakes when choosing panic/recover
Mistake #1: using recover for regular errors (error).
If you catch panic where you should have returned an error, you break readability and the function’s contract. Bad input, “not found,” validation — these are all normal branches and should be expressed explicitly via if ... { return ..., err }.
Mistake #2: sprinkling recover across the entire codebase.
This turns any bug into a “silent failure” instead of a fast signal. As a result, errors stop being reproducible and quickly diagnosable. Put one recover at the boundary (usually main) and treat it as a safety net, not a way of life.
Mistake #3: after recover, keep working as if nothing happened.
Mechanically, you can “intercept” a panic, but you cannot guarantee the correctness of intermediate state. The correct outcome is usually one: stop the operation, return an error, and give the user a predictable message.
Mistake #4: swallowing the panic value and losing context.
If you do catch a panic at the boundary, build an error with context: what you were doing and what was in panic(...). The panic value is “what arrived at the accident site”; don’t throw it away silently.
Mistake #5: panicking with the string “error,” without meaning and without details.
Yes, panic("error") technically works. But if this is truly an “impossible state,” give a clear message: what exactly turned out to be impossible. Your future self will thank you (or at least stop yelling at you).
GO TO FULL VERSION