panic! vs Result: Two Honest Ways to Fail

panic! vs Result: Two Honest Ways to Fail

The single judgment call that "being good at error handling" actually comes down to — when your code hits trouble, should it hand the problem to its caller, or stop right where it stands? A close read of a real panic message, the one case where .expect() is genuinely the right call, and the elegant trick of building a type that simply cannot hold an invalid value.

12 min read4 learning objectives

What You'll Learn

  • Explain why Rust splits "things going wrong" into two camps — recoverable and unrecoverable — and why that split shows up directly in a function's return type
  • Read a real panic message line by line, and know exactly what every part of it is telling you
  • Recognize the three narrow situations where reaching for panic! is genuinely the right call, and the one idea that ties all three together
  • Build a validating type like Guess, whose constructor is the only door in, so every other piece of code can simply trust its value is already correct

A few lessons back, right after showing you when reaching for .expect() is genuinely the right call, this course made you a specific promise: that there's a lesson coming where "we go all the way into that judgment call — panic versus Result, as a deliberate strategy." One lesson ago, the very last line of the HashMaps lesson called this exact topic "the part of the language most newcomers quietly dread — and that, once it actually clicks, usually becomes their favorite."

This is that lesson. And here's the good news hiding inside both of those quotes: the dread almost always comes from feeling like there's some secret rulebook you're supposed to have already memorized. There isn't. There's just one real question, and by the end of this lesson you'll be able to answer it instantly, every time: when something goes wrong here, does the calling code deserve a chance to recover — or has something already gone so wrong that pretending to recover would just be lying about it?

Two camps — and the line between them lives in the type signature

Plenty of languages answer this question for you, the same way, every time — usually by throwing an exception and letting it climb through layer after layer of code that never asked to think about it. Rust refuses to pick one answer for every situation, because there genuinely isn't one. Instead it hands you two completely different tools, and asks you — every single time something might go wrong — to pick whichever one actually fits:

  • panic! — stop. Right here, right now. Print exactly what went wrong, unwind the stack, end the program. No second chances, no recovery, no pretending things are fine.
  • Result<T, E> — the type you've been living with since a few lessons back. Hand the "this might not have worked" decision straight to the calling code — out in the open, written directly into the function's signature, impossible to quietly ignore.

Here's the part that's easy to miss the first time through: which of these a function uses isn't some implementation detail buried deep inside it. It's announced — right there in the signature, before you've read a single line of the body. A function that returns Result<T, E> is making you a promise: "this might not work, and that's a normal, expected part of using me — here's the value, you decide what happens next." A function that doesn't, and instead panics deep inside when things go wrong, is making a very different one: "if you ever see this crash, something is broken — and no amount of catching it would have made the broken thing not-broken."

panic! in practice

Run that, and the program does precisely what it says: stops, immediately, mid-stride. Now look closely at that output — does its shape ring a bell? It should. It's the exact same three-line pattern you watched a vector produce, entirely on its own, back in the vectors lesson, the moment you asked it for an element past its end. That wasn't a coincidence, and vectors don't have some special crash mechanism of their own. Indexing past the end of one simply calls panic! internally, with a message describing exactly what went wrong — same macro, same mechanism, same three honest lines, every time, whether Rust reaches for it or you do, by hand, on purpose.

Worth knowing exactly what each of those lines is telling you, since you'll be reading this shape for as long as you write Rust: the first line is where — which thread, which file, which exact line and column. The second is what — whatever message the panic carried. And the third is a standing offer: set the RUST_BACKTRACE=1 environment variable before you run the program again, and next time you'll get the entire call stack — every function that was active in the moment things went wrong, not just the last one that happened to notice.

So when does your code reach for it, on purpose?

Here's the honest answer, and it's shorter than you might expect: almost never, in finished code that deals with anything arriving from outside your program — user input, files, network calls, environment variables, other people's data. All of that can fail in completely ordinary, expected ways, and Result is how your code stays truthful about that, out loud, in its own signature.

panic! earns its keep in three much narrower situations:

  • Examples, prototypes, and tests. Code whose entire job is to demonstrate or verify an idea doesn't need production-grade recovery — a crash that says exactly what broke, the instant it broke, is the single most useful thing it could do.
  • A genuinely broken contract. If a function's documentation says "never call this with a negative number," and something calls it with -5 anyway, that isn't bad luck arriving from the outside world — it's a bug, sitting right there in your own code. No amount of gracefully "handling" it would turn the underlying mistake into a non-mistake; continuing to run with a value you already know is wrong just means computing nonsense and failing somewhere else, later, more confusingly, with a much colder trail leading back here.
  • Continuing would be unsafe — not just wrong, unsafe. Rare in everyday application code, far more common in the systems-level Rust this language was originally built for: situations where pressing on with bad data risks corrupting something, not merely returning a wrong answer.

Look at the thread running through all three: in every one of them, recovering genuinely wouldn't help. There's no sensible fallback value, no graceful path forward — just a program standing on ground that's already turned out not to be there.

Back to .unwrap() and .expect(), with the full picture in view

Which brings us right back to the "deliberate, occasional choice — not a habit" framing from the Option and Result lesson. Here's the example that makes the whole judgment call click into place for good:

Walk through why .expect() is exactly the right call here — not a shortcut someone will regret in three months. .parse() returns a Result, because in general, parsing text into an IpAddr absolutely can fail — somebody could hand it "not an address", and there'd be nothing to do but say so, plainly. But look at what's actually being parsed: a string literal, written directly into the source code, by you, the very same person writing this exact line. It cannot arrive malformed. It cannot change at runtime. There is no "calling code" to hand a decision to, because the only thing that could ever go wrong here is a typo you would make, right now, in the only place this value will ever come from. Reaching for .expect() with a message that says exactly that — "hardcoded, and known to be valid" — isn't laziness. It's the most honest sentence available at this exact spot in the code: "a Result exists here for reasons that simply don't apply to this particular value — and here is precisely why."

The cleanest trick of all: a type that cannot hold a wrong answer

Here's where panic! stops being just "the emergency stop" and starts being something genuinely elegant — a tool for designing code that's harder to get wrong in the first place.

Picture a small guessing game that only makes sense for whole numbers from 1 to 100. The straightforward approach checks that range everywhere a guess gets used — when it's read, compared, displayed, anywhere at all. Which means the one rule the entire program depends on is now scattered across a dozen call sites, with nothing whatsoever stopping a thirteenth one from quietly forgetting to check.

Or — you could build a type that simply refuses to exist any other way:

Look at the shape of what just happened, piece by piece — every part of it something you've already met somewhere else:

  • The field value carries no pub. Code outside this type has no door in to set it directly — exactly how Rust draws and enforces that boundary is its own lesson, a little further down the road, but the shape of the idea is already sitting right in front of you.
  • The only way in is Guess::new — an associated function, the very same Type::new(...) constructor pattern from the method-syntax lesson.
  • And new is the one and only place that checks. Hand it 200, and you don't get back a Guess quietly holding a wrong number — you get a panic, immediately, with a message stating precisely which rule got broken and exactly which value broke it.

Which means: the moment a Guess exists at all, every piece of code that ever receives one can simply trust — no redundant checking, no "just in case" validation copy-pasted into one more spot — that its number is already, certainly, somewhere between 1 and 100. One validation. Written once. Trusted forever after, by every single piece of code that ever touches the type, including the version of you who revisits this file in a year and has completely forgotten this rule ever needed enforcing. That's not just error handling — that's letting the type system carry a promise, so nobody ever has to remember to check it by hand again.

Quick exercise

Build your own version of this trick: a Percentage type that can only ever hold a whole number from 0 to 100 — validated once, inside Percentage::new, exposed read-only through a .value() getter, exactly like Guess. Then take thirty seconds and actually picture it: every place in a real codebase that used to need its own little range check — a progress bar, a volume slider, a battery indicator, a discount field — and ask yourself how many of those checks would simply stop needing to exist, the instant a type like this became the only way to hold the number in the first place.

You can now do the thing that "being good at error handling" actually comes down to: look at a piece of code that might go wrong, and know — immediately, with real reasons behind it, not a guess — whether it should hand the problem to its caller or stop the program right where it stands. That's the judgment call. You have it now, for good.

What you don't have yet is the comfortable way to act on the "hand it to the caller" half of that decision — and right now, doing it by hand means writing the same two-armed match block over and over, every single place a Result needs to flow upward through your code. There's a single character that erases all of that repetition — the very one you watched ride quietly along in the Option and Result lesson's closing example, doing something that looked almost like magic. Time to find out exactly how it works: propagating errors with the ? operator.