---
title: 'Panic and Unrecoverable Errors'
source: 'https://academia.sh/en/courses/rust-abstraction/panic-and-unrecoverable-errors'
course: 'Traits, Generics and Error Handling'
language: en
updated: '2026-09-15T04:58:42+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Compares by executing a recoverable and an unrecoverable design how many times the remaining body runs'
    - 'Explains how the same distinction can be modeled without actually triggering a panic'
    - 'Distinguishes with an example that choosing panic is a design decision, not an error kind'
---

# Panic and Unrecoverable Errors

Modeling side by side a design where a failure stops the remaining body without leaving the caller any choice, and a design where the same failure is met with a return value, and comparing the counts the two designs give for the remaining body.

The Go Fundamentals course actually triggered a panic and caught it with a recovery
call, printing a result in the form `panic=true` / `panic=false` — the panic made a
stack trace visible, while the recovery itself made only a flag visible. That course
could use this catching mechanism because there the panic was triggered at the boundary
of a single call, in a controlled way. This lesson measures the same distinction without
ever triggering it: a real panic is neither set up here nor caught with a recovery call.
Panic text is environment-dependent data — the file name, line number, and the shape of
the call stack vary with the machine it runs on — and this lesson does not print such
text.

Instead, the distinction is **modeled**: the same failure is built once in a design met
with `Result`, and once in a design written in the form "nothing after this point runs,"
and the counts the two designs give for the remaining body are compared. The Custom
Error Types lesson showed that a failure can be carried as a value, and that the caller
decides for itself what to do with that value. This lesson measures a design where that
decision is *refused*: a failure that leaves the caller no choice.

Every mechanism measured in this topic's previous four lessons — absence, failure arms,
the shorthand, conversion — shared one common assumption: failure was a *value*, and the
program kept running along with that value. This last lesson looks for the limit of that
assumption: can every failure be handled this way, or is the "continue" option itself
meaningless for some situations? An unrecoverable situation is, by definition, one where
an assumption too fundamental to return as a `Result` and leave to the caller has
collapsed — and instead of separating these two kinds from each other, this lesson
counts the effect both have on the *remaining body*.

## Two Designs, the Same Condition

The two words in the lesson title — panic and unrecoverable error — are two names for
the same idea. "Panic" is how Rust's runtime names this state; "unrecoverable error" is
the same state's conceptual name, not specific to Rust alone. This lesson treats both
under the same measurement: a failure stopping the remaining work without leaving the
caller the option to continue.

The two runs below handle the same invalid input (`-5`) with two different designs. In
the first, the failure is met with a `Result`, and the caller continues its remaining
work even after seeing the error.

```rust
// recoverable.rs — the error is a value, the caller chooses to continue
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::rc::Rc;

type Ledger = Rc<RefCell<BTreeMap<String, u32>>>;

fn record(ledger: &Ledger, name: &str) {
    *ledger.borrow_mut().entry(name.to_string()).or_insert(0) += 1;
}
fn times(ledger: &Ledger, name: &str) -> u32 {
    *ledger.borrow().get(name).unwrap_or(&0)
}

fn validate(input: i32) -> Result<i32, ()> {
    if input < 0 {
        return Err(());
    }
    Ok(input)
}

fn remaining_work(ledger: &Ledger) {
    record(ledger, "recoverable-continue");
}

fn main() {
    let ledger: Ledger = Rc::new(RefCell::new(BTreeMap::new()));

    match validate(-5) {
        Ok(value) => println!("valid value: {}", value),
        Err(()) => println!("invalid input met, program continues"),
    }
    remaining_work(&ledger);

    println!();
    println!("recoverable-continue: {} times", times(&ledger, "recoverable-continue"));
}
```

```
invalid input met, program continues

recoverable-continue: 1 times
```

**EH13.** `validate(-5)` returns an `Err`, but this return does not stop the program:
`match` runs the `Err` arm, then the flow reaches `remaining_work` in the ordinary way
and runs it. `recoverable-continue` climbs to one. The caller had an option — seeing the
error and continuing — and it used that option.

In this design, `validate` reports its failure with exactly the mechanism measured in
this topic's previous four lessons: it returns `Result<i32, ()>`, the `Err` arm carries
no value (`()`), but its presence is enough. `main`'s body meets this value with a
`match`, and in both arms the program reaches the same place, `remaining_work`. Calling
`remaining_work` is not a result of the `match` — it is written *after* the `match`
finishes, as an ordinary next line. This is a difference from the pattern seen in
previous lessons: there, the arms did independent jobs; here, both arms lead to the same
next step, only printing a different message.

The second design handles the same condition without ever offering the caller this
option.

```rust
// unrecoverable.rs — when the condition fails, the remaining body is never called by design
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::rc::Rc;

type Ledger = Rc<RefCell<BTreeMap<String, u32>>>;

fn record(ledger: &Ledger, name: &str) {
    *ledger.borrow_mut().entry(name.to_string()).or_insert(0) += 1;
}
fn times(ledger: &Ledger, name: &str) -> u32 {
    *ledger.borrow().get(name).unwrap_or(&0)
}

fn continue_work(ledger: &Ledger) {
    record(ledger, "unrecoverable-continue");
}

fn main() {
    let ledger: Ledger = Rc::new(RefCell::new(BTreeMap::new()));
    let input = -5;

    if input >= 0 {
        println!("valid value: {}", input);
        continue_work(&ledger);
    } else {
        println!("unrecoverable condition: the remaining body is never called by design");
    }

    println!("unrecoverable-continue: {} times", times(&ledger, "unrecoverable-continue"));
}
```

```
unrecoverable condition: the remaining body is never called by design
unrecoverable-continue: 0 times
```

**EH14.** The same condition (`input < 0`) this time results in `continue_work` never
being called. `unrecoverable-continue` stays at zero — written in the source, but
running **never** in this run. Putting the two runs side by side makes the difference
clear: the same invalid input leads to the remaining work running in one design and
never running in the other. The count comes from the design itself — not from how
"invalid" the input is.

This second design has no `Result` at all — `main` itself decides with `if`/`else` and
calls `continue_work` only in the valid branch. The `else` arm itself prints a message,
but this message is not an error *value*, only an informational note; the program itself
ends normally after this message. This lesson presents this second design as a *model*
of a real unrecoverable situation — a real unrecoverable situation typically stops the
program without even waiting for `main` to finish; here `main` completes normally, only
the `continue_work` call ends up skipped. The model carries the "remaining work does not
run" outcome; it does not carry when and how the program itself terminates.

Neither of these two programs shows a real panic; both are written with ordinary
`if`/`match` branching, and both terminate normally. The model carries not the panic
state's *actual mechanism* (unwinding the call stack) but its *observable outcome* (the
remaining body never running). A real panic produces this "never runs" outcome
automatically, in a single call, without stopping every function in the intermediate
layers one by one — this lesson does not build that automation, it only compares its
outcome with two hand-written designs.

Why this modeling is necessary comes from the ban on environment-dependent data. When a
real panic is triggered, the runtime produces a text; that text includes which file and
line it stopped at, and the call stack's shape at that moment. All of this information
depends on the build environment, the file path, and even the compiler's version — the
same source code does not produce the same text when compiled and run on a different
machine. The rule kept throughout this course (no printing of real paths, real memory
addresses, version-dependent output) applies here too; this is why the panic state is
not really triggered, and instead only the part that *can be counted* — whether the
remaining body runs — is modeled.

This modeling belongs to the same family as the rule measured in the previous four
lessons that "code the compiler rejects is not placed in a code block." There, the
compiler's rejection was described in prose, not placed in a code block; here, the panic
state itself is never set up at all, and its outcome is modeled instead. What both have
in common is putting a number that can be read from the source in place of
environment-dependent output.

## A Limit on the Measurement

The difference between the two designs above does not say which one is "correct." Panic
is not a kind of error, it is a design decision — the same failure can be written in
either form, and the choice depends on what is left to the caller.

**EH15.** The recoverable design leaves the caller an option: ignoring the error, using
a fallback, or producing its own error. The design modeled as unrecoverable closes off
that option from the start — the remaining work never runs by design unless the
condition is met, and changing that is not in the caller's hands. A real panic takes this
second design one step further: the stop happens not only in one function's body but in
every layer that calls that function, without any explicit code being written. This
lesson does not exercise this multi-layered stop; it only shows, at a single layer, how
the "never runs" outcome can be modeled.

This difference can be compared to the silent skipping measured in the Shorthand for
Error Propagation lesson, but it is not the same thing. There, early return produced a
`Result`; the remaining body did not run, but the function returned normally, and the
caller could meet that return with a `match`. In this design modeled as unrecoverable,
the function also returns normally — the model is not a real panic — but a real panic
differs at exactly this point: it produces no normal return, it leaves the caller no
value to meet with a `match`. This is the real dividing line between recoverable and
unrecoverable: one leaves a value, the other cuts the flow without leaving any value at
all.

In the design modeled as unrecoverable, `continue_work` was written as a single body,
but in a real program the remaining work could consist of several steps — opening a
record, computing a total, printing a result. This lesson gathered these steps into a
single function, but the multi-step skipping seen in the Shorthand for Error Propagation
lesson applies here too: when the `input >= 0` condition is not met, all the steps
*inside* `continue_work` are skipped together, not one by one. How many separate steps
the remaining work consists of does not matter — as long as the condition is not met,
none of them run, all of them together fall into the "never" class.

The moment a designer has to choose between these two paths is usually decided by the
answer to "can this situation actually happen, and if it does, does the rest of the
program stay trustworthy." A user's input being invalid is an expected situation — the
recoverable design is the natural choice here, because the caller can anticipate this
situation in advance and keep a response ready. By contrast, a situation that breaks a
program's own internal consistency — an array carrying fewer elements than expected, a
setup left incomplete — is usually treated as unrecoverable, because in that situation
the assumptions the rest of the code was written under are no longer valid, and
continuing could lead to new, unpredictable errors.

Drawing a precise line between these two extremes is not this lesson's job — the line
depends on the program's own domain and the designer's decision. This lesson only makes
the *outcome* of that decision countable: whichever design is chosen, whether the
remaining body runs can be verified not by reading the source, but by measuring a run.
The `1`/`0` difference between `recoverable-continue` and `unrecoverable-continue` is the
last example of the measurement carried throughout this topic: how many times a body
written in the source runs in a run.

Whether a design ends up recoverable or unrecoverable does not follow automatically from
the failure's *nature* — it is a decision. The same invalid input can be handled in one
program as "log the error, continue with a default value"; in another program, the same
input can be handled as "this should never have happened, continuing means nothing."
Rust itself does not impose this decision; both `Result` and an ordinary function return
are part of the same language, and the choice belongs to whoever writes the source code.

The `-5` input to the `validate` function above actually represents the "same" failure
in both designs — in both, the source says a negative number is considered invalid. The
difference is not in *detecting* the failure, but in *what happens after* it is
detected. This stands in the same place as the axis carried throughout this topic: how
many times a body written in the source runs in a run. Only this time the body being
measured is not a fallback value or a conversion, but the *rest* of a function's normal
flow — and whether that rest runs is read directly from the design itself.

## Summary

- The same failure can be written with two designs: in the recoverable design, the
  caller continues the remaining work by its own choice; in the design modeled as
  unrecoverable, the remaining body runs **never**.
- A real panic was never triggered in this lesson; the distinction was modeled with
  ordinary branching, without printing panic text — because panic text carries
  environment-dependent data like the file, line, and call stack.
- In the recoverable design the remaining work ran (`recoverable-continue: 1`), in the
  design modeled as unrecoverable it did not (`unrecoverable-continue: 0`) — the same
  condition, a different count.
- Panic is not a kind of error but a design decision; the choice depends on what is left
  to the caller — on whether a situation is expected or breaks the program's internal
  consistency.
- A real panic produces the "never runs" outcome in a single call, without stopping the
  intermediate layers one by one; this lesson measured not that automation, only its
  observable outcome.
- In the recoverable design, the caller meets a value and decides; in the unrecoverable
  design, no value is left at all, the flow is cut directly.

## Next Step

The bodies measured throughout this topic's five lessons were always inside a
function's own body — how much of it ran when a function was called: a fallback value,
an arm, a conversion, a remaining body. All five lessons settled into the same three
classes (every time, only when needed, never), only which mechanism fell into these
classes changed. The Closures lesson carries the same question to *when* a body is
called: does a closure run the moment it is defined, or only when someone calls it? The
"how many times did it run" question measured in this topic continues there too, but now
what is measured is no longer a failure — it is a definition itself.
