Rust Result Handling: Option, ? Operator, Custom Errors
Use Result for recoverable errors, Option for absence, and ? to propagate via From.
20+ years shipping production backend systems. Drawn from code that ran under real load.
- ✓Rust ownership, borrowing, and basic enum matching
- ✓Cargo projects, modules, and running cargo test
- ✓Std I/O and command-line env basics
- Result
is for recoverable failures with data; Option is for absence that is normal, not exceptional - Use map/and_then to transform without nesting, or_else/unwrap_or_else for fallback with lazy defaults
- Apply ? to propagate with automatic From conversion; bridge Option with ok_or before using ? in Result functions
- Reserve panic, unwrap, and expect for startup invariants and tests; return Result from request paths and libraries
- Design one error enum per crate with Display, Error, source chains, and From impls for automatic ? upgrades
- Start binaries on anyhow-style context, expose thiserror-style typed enums from libraries, migrate hot paths first
Think of a restaurant kitchen where every order ticket either comes back with a plated dish or with a specific note saying what went wrong and what to do next, like missing ingredients or a broken oven, instead of the cook silently vanishing or shouting and shutting down the whole restaurant. Rust works the same way: functions hand back either the value you asked for or a labeled reason for failure, and the compiler acts like a strict head chef who refuses to serve anything until you have written down what happens in each case, which means fewer nasty surprises during the dinner rush.
You've written the happy path and it compiles, then the first malformed input arrives and your service panics on unwrap. That's the moment most Rust developers realize error handling isn't ceremony, it's the design.
Rust has no exceptions and no null, so every failure is a value you've got to route somewhere. Result carries typed failures, Option marks normal absence, and the compiler rejects code that ignores either one silently.
The learning curve feels steep because early code drowns in match blocks and borrowed-value fights. You'll clone to satisfy the checker, stringify errors to silence type mismatches, and wonder whether combinators are worth learning.
They're worth it, because production errors are policy decisions. Retry, fallback, reject, or page — each choice lives in how you convert, propagate, and log. Typed enums drive those choices; strings can't.
You'll leave with a working playbook you can apply today. You'll know when to pick Result versus Option, how to chain without nesting, where ? converts for you, and how to draw a panic line your team can enforce in CI.
Result and Option Anatomy: How Ok, Err, Some, and None Work
Result and Option are enums, but that label undersells how much work they do in Rust 2024. Result<T, E> has two variants: Ok(T) for the happy path and Err(E) for a recoverable failure that carries a value. Option<T> has Some(T) for presence and None for absence. There is no null, no undefined, no exception that jumps across frames. Every fallible operation returns its failure as data, and the compiler forces you to acknowledge it before your program builds. That single constraint removes a whole class of production crashes that plague null-accepting languages, where a missing check surfaces at 3 AM as a NullPointerException three frames away from the real cause.
Memory layout matters more than most tutorials admit. Option<&T> and Option<NonZeroU32> occupy the same space as the bare pointer or integer, because the compiler applies niche-filling: it steals an invalid bit pattern of the inner type to represent None. Result<T, E> cannot always pull that trick, since both sides may need full range, so it stores a discriminant. In practice this means Option is free at runtime and Result costs one discriminant check on match. Both are stack values with no allocation. Cloning, moving, and dropping follow normal ownership rules, so an Err(String) owns its message and cleans it up when it goes out of scope. You never free error memory by hand.
Pattern matching is the honest way to consume these types. A match on Result forces two arms, and a match on Option forces two arms. The compiler rejects a missing arm, which is the feature, not friction. Early in a codebase you will write verbose matches to log context, convert error kinds, or pick defaults. That verbosity teaches the control flow: each arm is a distinct state your service can be in, and each deserves an explicit decision. Teams that skip this step by unwrapping everywhere lose the audit trail of which failure modes they considered.
The 2024 edition did not change Result or Option semantics, but it sharpened the surrounding ergonomics. Let-else, if-let chains, and improved match ergonomics make it cheaper to destructure without cloning. You can write let Some(port) = cfg.port else { return Err(ConfigError::MissingPort); }; and move on. Borrow rules still apply: matching on &result avoids moving the payload when you only need to inspect it. Newcomers from garbage-collected languages often clone defensively to satisfy the borrow checker. Resist that urge in hot paths. Borrow first, clone at the boundary where ownership must transfer, and let the compiler guide you.
A senior habit is to read function signatures as contracts. fn parse_port(s: &str) -> Result<u16, ParseError> promises a 16-bit port or a typed reason for refusal. fn find_user(id: u64) -> Option<User> promises that absence is normal, not exceptional. Confusing the two is a design smell. If absence is expected — a cache miss, an optional header, a user who never set a nickname — return Option. If the caller must decide between retry, fallback, or abort — a timeout, a permission denial, a corrupt row — return Result. Callers can convert between them with ok_or and ok, but the original choice documents intent, and intent is what survives code review six months later.
In code review, look for two smells that signal weak anatomy understanding. First, functions returning Result<String, String> with ad-hoc messages that callers cannot match. Replace them with enums before the API stabilizes, because every stringly error becomes a breaking change later when you finally type it. Second, helpers returning Option where absence has multiple causes — a lookup that can miss because the key is invalid versus the store is empty. Split those into Result with InvalidKey and NotFound variants so metrics distinguish user mistakes from capacity gaps. These refactors take an afternoon and pay back every incident review for a year.
Apply this in your next pull request: pick one helper that returns String errors or unwraps, convert it to a typed Result with tests for each arm, and wire ? through its callers. Small migrations compound into reliable services. Measure the result with cargo test counts for new arms, clippy denies holding at zero unwraps, and log queries showing stable stems. Reviewers confirm the policy boundary handles each variant once, dashboards group cleanly by prefix, and on-call notes record the remedy beside the alert. Repeat the loop weekly until stringly errors disappear from the crate.
Map and Map_Err: Reshape Values Without Branching
Map is the combinator you reach for when the success value needs reshaping and the error can pass through untouched. result.map(|v| v * 2) applies the closure only on Ok, leaving Err exactly as it was. map_err is the mirror: it rewrites only the error side, so result.map_err(|e| Error::Wrap(e.to_string())) normalizes a low-level failure into your domain type without touching Ok. Option has map as well, plus map_or and map_or_else for supplying defaults. The point of these adapters is to keep the happy path linear while error handling stays declarative. You describe the transformation, not the branching.
Chaining maps beats nested matches for readability once you trust the types. Consider reading an env var, parsing it, then clamping it: std::env::var("PORT").map_err(|e| e.to_string()).and_then(|s| s.parse::<u16>().map_err(|e| e.to_string())).map(|p| p.max(1024)). Each stage only runs if the prior stage succeeded. There is no indentation pyramid, no temporary mutable, no early return noise. If any stage fails, the original error short-circuits to the end with its payload intact. Reviewers see the pipeline at a glance and can reason about each link independently.
Ownership inside closures deserves attention. map takes self by value, so chaining consumes intermediate Results. That is usually what you want in a pipeline, but it means you cannot reuse the original binding afterward unless it is Copy. When you need to inspect without consuming, call .as_ref() first to get Result<&T, &E>, then map over the reference. Similarly, .as_mut() yields mutable access for in-place fixes. These two adapters are cheap and avoid clones. Teams that clone the whole Result to peek at it pay for an allocation they never needed, and the cost shows up in profiles of request-heavy services.
A common junior mistake is using map where and_then belongs. map expects the closure to return a bare value, producing Result<Result<T, E>, E> if you return a Result inside it — a nested type that rarely compiles where you need it. and_then expects the closure to return another Result, flattening automatically. The rule is mechanical: transformation returning plain T uses map; fallible step returning Result<U, E> uses and_then. Option follows the same split with map versus and_then. Memorize that pairing and half of all combinator type errors disappear.
In production code, map_err earns its keep at crate boundaries. Your HTTP handler should not leak std::num::ParseIntError or std::io::Error to callers; it should return AppError with a stable variant. A single .map_err(AppError::from) or an explicit closure at the boundary converts once, and inner functions keep their precise error types for unit tests. This layered approach preserves diagnostic detail where it is produced and presents a stable contract where it is consumed. Logs stay actionable because each layer adds context instead of stringifying and discarding the source chain.
Reviewers should also probe map_err discipline at module edges. Inner parsers can return narrow types like ParseIntError, but public functions must map them into domain errors with the input attached. A message like bad port without the offending string forces responders to reproduce blindly. Attach the input, the valid range, and the source error in one structured variant. Property tests help here: feed random strings into parsers and assert every failure maps to a documented variant rather than panicking. Teams that add those tests catch niche panics on unicode and boundary values before users do.
Apply this in your next pull request: pick one helper that returns String errors or unwraps, convert it to a typed Result with tests for each arm, and wire ? through its callers. Small migrations compound into reliable services. Measure the result with cargo test counts for new arms, clippy denies holding at zero unwraps, and log queries showing stable stems. Reviewers confirm the policy boundary handles each variant once, dashboards group cleanly by prefix, and on-call notes record the remedy beside the alert. Repeat the loop weekly until stringly errors disappear from the crate.
And_Then, Or_Else, and Unwrap_Or: Pipelines With Fallbacks
and_then and or_else form the backbone of fallible pipelines. and_then chains a second computation that itself can fail: read config, then connect, then authenticate. or_else recovers: try primary, then fall back to replica on a specific error kind. Combined with map, they let you express retry, fallback, and validation without a single explicit match. The combinator version is shorter, but the real win is composability. Each link is a function you can test alone, and the chain reads in execution order from top to bottom.
The unwrap_or family handles the moment you decide a failure has a safe default. unwrap_or(value) substitutes eagerly, unwrap_or_default() uses Default::default(), and unwrap_or_else(|| compute()) defers work until failure actually happens. That laziness matters when the default is expensive — building a default config, opening a fallback file, computing a hash. unwrap() and expect() are different beasts: they panic on failure and belong in tests, examples, and provably unreachable paths, never in request handling. Clippy will flag careless unwraps in CI if you enable nursery lints, and you should.
Consider a startup sequence that tolerates missing optional config but not a missing database URL. let pool_size: usize = env::var("POOL").ok().and_then(|s| s.parse().ok()).unwrap_or(16); uses Option pipelines to degrade gracefully to 16 workers. By contrast, let db_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); panics fast at boot when a required value is absent. Both are one-liners, but their failure semantics are opposite: degrade versus refuse to start. Choosing correctly is an architectural decision, not a style preference. Degrade for tuning knobs, refuse for integrity requirements.
Lazy defaults also prevent a subtle performance trap. unwrap_or(expensive_default()) always evaluates the argument, even on the happy path. In a loop serving ten thousand requests per second, that wasted work is measurable. unwrap_or_else(|| expensive_default()) runs only on None or Err. The same split exists for map_or versus map_or_else on Option. Senior reviewers check every unwrap_or call in hot paths and demand the _else variant when the default is not a constant. It is a two-word change that removes allocation churn under load.
Testing combinator chains is straightforward because each closure is pure. Unit tests feed Ok and Err inputs and assert outputs, without spinning up servers or files. Integration tests then cover the full chain with real I/O. This separation is why combinator-heavy code has higher coverage with fewer mocks: business logic lives in small closures, I/O lives at the edges, and the type system wires them together. When a chain grows past three links, extract named functions for each link. The chain stays readable, stack traces name real functions, and reuse becomes possible across handlers.
A practical review checklist keeps pipelines honest. Every or_else fallback names the error kinds it handles and re-raises the rest, so unexpected failures do not masquerade as handled. Every unwrap_or default is a constant or uses the _else form when allocation is involved. Chains longer than three links get named functions with unit tests per link. Apply that checklist for a month and combinator code stays flat, explicit about recovery scope, and cheap under load. Dashboards confirm the effect: fewer panics, clearer retry counts, and defaults that show up as intentional config rather than mystery values.
Apply this in your next pull request: pick one helper that returns String errors or unwraps, convert it to a typed Result with tests for each arm, and wire ? through its callers. Small migrations compound into reliable services. Measure the result with cargo test counts for new arms, clippy denies holding at zero unwraps, and log queries showing stable stems. Reviewers confirm the policy boundary handles each variant once, dashboards group cleanly by prefix, and on-call notes record the remedy beside the alert. Repeat the loop weekly until stringly errors disappear from the crate.
Bridging Option and Result: Ok_Or, Transpose, and Filter
Option and Result are interconvertible, and fluency with the bridges separates beginners from practitioners. ok_or(err) turns Option into Result by supplying the error for None; ok_or_else(|| err) defers construction. ok() turns Result into Option by discarding the error, which is appropriate when the caller genuinely cannot act on the reason — a cache probe, a best-effort lookup. transpose() flips Option<Result<T, E>> into Result<Option<T>, E>, a lifesaver when iterating over optional fallible values. These adapters are zero-cost and preserve ownership, moving payloads rather than copying them.
Filtering and flattening complete the toolkit. filter(|v| v.is_valid()) turns Some into None when a predicate fails, which models validation of optional input in one step. flatten() collapses Option<Option<T>> and Result<Result<T, E>, E> one level, cleaning up after code that nests through map. flatten is stable since Rust 1.40 for Option and widely used after iterator pipelines that produce nested types. Without it you end up matching twice for what is logically one decision, and reviewers will ask you to simplify.
A realistic example is parsing optional HTTP headers. let retry_after: Option<u64> = headers.get("retry-after").and_then(|v| v.to_str().ok()).and_then(|s| s.parse().ok()).filter(|&n| n <= 3600); Each and_then handles one fallible step, filter enforces a policy cap, and the final type is Option<u64> with None meaning absent, malformed, or out of policy. The handler then uses unwrap_or(default) or ok_or(AppError::BadHeader) depending on whether the header is optional or required. One expression covers four states without branching noise.
Watch the ownership cost of ok_or versus ok_or_else. ok_or builds the error eagerly on every call, even when the Option is Some and the error is discarded. If constructing the error allocates — format! with context, capturing a backtrace string — that allocation happens on the happy path for nothing. ok_or_else(|| ...) defers it to the None case. The same eager-versus-lazy split appears everywhere in this module, and the discipline is identical: constants can be eager, anything allocating must be lazy. Profiles of JSON APIs have shown 5 to 8 percent allocation reduction from sweeping ok_or to ok_or_else in header and query parsing.
The bridge in the other direction, Result::ok(), deserves a warning. Discarding errors is sometimes correct, but it must be deliberate and logged. let _ = cache.insert(k, v).ok(); is fine when the cache is best-effort and the next read will simply miss. let _ = db.commit().ok(); is a data-loss bug wearing a quiet disguise. A team rule that works well: every .ok() call on a Result outside tests needs a comment or a debug log explaining why the error is safe to drop. That single convention turns silent swallowing into documented intent.
Testing bridges deserves explicit cases for each direction. For ok_or paths, test Some with valid data, Some with invalid data, and None, asserting distinct errors for the latter two so operators can tell malformed from missing. For ok paths, test that discarded errors are truly best-effort by asserting side effects still converge — a cache insert failure must still serve from origin. Add a regression test for eager allocation by counting allocations or simply reviewing diffs for format! inside ok_or on hot paths. These tests are short, run in milliseconds, and prevent the quiet data-loss bugs that survive integration suites.
Apply this in your next pull request: pick one helper that returns String errors or unwraps, convert it to a typed Result with tests for each arm, and wire ? through its callers. Small migrations compound into reliable services. Measure the result with cargo test counts for new arms, clippy denies holding at zero unwraps, and log queries showing stable stems. Reviewers confirm the policy boundary handles each variant once, dashboards group cleanly by prefix, and on-call notes record the remedy beside the alert. Repeat the loop weekly until stringly errors disappear from the crate.
The ? Operator: Propagation in Main, Functions, and Tests
The question-mark operator is sugar for a match that returns early on Err, and it is the reason Rust error handling scales to real programs. Writing let cfg = load_config()?; inside a function returning Result expands to a match that yields the Ok payload or returns the Err after converting it with From. No explicit branch, no indentation growth, no lost context. Five fallible calls in sequence read as five lines, each with ?, and the function either produces its value or exits with the first failure. That linearity is what makes large Rust services readable despite pervasive fallibility.
The operator works in any function returning Result or Option, and the conversion rule is precise. In a Result function, ? on Result applies From to the error, enabling automatic type upgrades along the call stack. ? on Option inside an Option function returns None early with no conversion. Mixing them requires explicit bridges: .ok_or(AppError::Missing)? turns an Option into a Result-compatible early return. The compiler error when you mix them is helpful — it names the mismatched types and suggests ok_or — so read it fully instead of reaching for unwrap to silence it.
Main functions and tests have special support that newcomers miss. fn main() -> Result<(), Box<dyn std::error::Error>> lets you use ? at the top level and get a Debug print plus nonzero exit on failure, which is ideal for CLIs and migration binaries. Tests return Result too: #[test] fn parses() -> Result<(), MyError> { let v = parse("42")?; assert_eq!(v, 42); Ok(()) } propagates instead of panicking mid-assertion setup. This pattern removes expect noise from test scaffolding and surfaces the actual error value on failure rather than a generic unwrap panic line.
Error conversion through ? is where custom types earn their place. If each layer defines its own error enum and implements From<LowerError> for UpperError, then ? automatically lifts lower failures as they bubble. No manual map_err at every call site, no string concatenation that destroys matchability. The rule for library authors is strict: expose a precise error enum, implement std::error::Error, and provide From conversions for wrapped kinds. Application authors then choose whether to keep the layered enum or erase into a boxed trait object at the binary edge.
There is a performance story here as well. ? compiles to a branch on the discriminant with no allocation, no unwinding, and no hidden control flow beyond an early return. It is as cheap as a manual match, because it is a manual match after desugaring. The binary size impact is minimal — one branch per site — and branch prediction favors the Ok path in healthy services. Teams sometimes fear that pervasive ? adds overhead versus C-style error codes. It does not. The codegen is equivalent, with the added benefit that the compiler proves you handled every failure instead of trusting discipline.
Adoption goes smoother with two team conventions. First, every function returning Result documents its error type in one doc line: returns ConfigError with MissingField when env is absent. That line shows up in rustdoc and stops misuse at the call site. Second, prefer ? over manual match for straight-line fallible sequences, but keep explicit match where you add context, metrics, or policy branches. Blanket rules either way create noise. Mixed style with clear intent — linear propagation by default, explicit handling at policy points — reads best in large services and survives refactors without churn.
Apply this in your next pull request: pick one helper that returns String errors or unwraps, convert it to a typed Result with tests for each arm, and wire ? through its callers. Small migrations compound into reliable services. Measure the result with cargo test counts for new arms, clippy denies holding at zero unwraps, and log queries showing stable stems. Reviewers confirm the policy boundary handles each variant once, dashboards group cleanly by prefix, and on-call notes record the remedy beside the alert. Repeat the loop weekly until stringly errors disappear from the crate.
main() -> Result<(), Box<dyn Error>> for CLIs and fn test() -> Result<(), E> for tests. You get ? support plus clean failure output.Custom Error Enums: From, Source Chains, and Policy
Custom error enums are how production Rust code stays diagnosable. A flat String error tells operators something broke but never which subsystem, what input, or whether retry is safe. An enum with variants like Io(std::io::Error), Parse(ParseIntError), MissingField(&'static str), and Unauthorized { user: String } encodes all three answers in the type. Callers match on variants to decide policy: retry on timeout, reject on validation, page on I/O. Strings cannot drive policy; enums can. That distinction is the difference between alerting that fires correctly and alerting that pages for user typos.
Implementing the Error trait by hand is ten lines and worth doing once to understand the machinery. You derive Debug, implement Display for human messages, implement std::error::Error with optional source() chaining, and add From impls for each wrapped type so ? converts automatically. The source chain preserves causality: AppError::Io(e) returns Some(e) from source(), so error reporters walk the chain and print Caused by lines. Without source, you get the top message and lose the root cause, which is exactly what makes midnight debugging painful.
A frequent debate is how many error types a crate should have. The pragmatic answer is one public enum per crate or per module boundary, with private sub-errors converted at the edge. Small CLIs can survive on Box<dyn Error> plus context strings. Mid-size services need 6 to 12 variants grouped by subsystem: config, storage, network, auth, validation. Beyond 20 variants the enum becomes a dumping ground; split the module. Each variant should carry the minimum data to act: the path that failed, the key that was missing, the status code received. Timestamps and backtraces belong in logs, not in the type.
NonExhaustive and extensibility matter for libraries. Marking a public error enum #[non_exhaustive] lets you add variants without breaking downstream matches, at the cost of forcing a wildcard arm on consumers. Most application enums should stay exhaustive so the compiler lists unhandled cases after you add a variant — that checklist is valuable during refactors. Library enums exposed to external users lean nonexhaustive for semver safety. Document which variants are retryable directly on each variant with doc comments, because retry policy is part of the public contract.
Error enums also interact with Send and Sync bounds for async runtimes. Tokio tasks require errors that cross await points to be Send, which String and most std errors satisfy, but Rc-based payloads do not. Prefer Arc over Rc inside errors that travel across threads, and avoid holding MutexGuards in error data. A compile error about future not Send that points at your error type is the runtime telling you the error could cross threads. Fix the type, not the spawn call, by making the payload Send-safe from the start.
Variant design benefits from writing the match sites first. Draft the caller code that retries timeouts, rejects invalid input, and pages on I/O, then define exactly the variants those arms need. This outside-in order prevents speculative variants that never match in practice. It also keeps enums small: if no caller distinguishes two failures, merge them into one variant with a reason field. Revisit the split quarterly using alert data — the variant with the most pages deserves finer granularity, while silent variants can merge. Error design follows incident data, not imagination.
Apply this in your next pull request: pick one helper that returns String errors or unwraps, convert it to a typed Result with tests for each arm, and wire ? through its callers. Small migrations compound into reliable services. Measure the result with cargo test counts for new arms, clippy denies holding at zero unwraps, and log queries showing stable stems. Reviewers confirm the policy boundary handles each variant once, dashboards group cleanly by prefix, and on-call notes record the remedy beside the alert. Repeat the loop weekly until stringly errors disappear from the crate.
A final drill cements the habit: write three unit tests per helper covering success, expected failure, and unexpected failure, then run them under cargo test with RUST_BACKTRACE=1 to inspect chains. Keep each test under ten lines so the suite stays fast enough to run on every save.
source(), reporters print only the outer line and hide root cause. Return Some(inner) so Caused by chains survive to logs.source() impls hid ENOENT behind storage failure for 3 weeks. Adding one-line source matches restored Caused by lines and cut triage 25 minutes.Panic Policy: When Unwrap, Expect, and Assert Belong
Panic policy is a reliability decision, not a stylistic one. panic! unwinds the stack, runs destructors, and — in the default profile — poisons locks and aborts the thread, not the process. In a multithreaded server, one panicking request thread does not crash the binary, but it drops that request, poisons any Mutex it held, and leaves metrics half-updated. unwrap() and expect() are panic vectors with the same blast radius. expect adds a message, which helps postmortems, but the thread still dies. Production services treat every panic as a bug report, alert on it, and drive the count to zero on steady state.
The policy that survives audits is simple: panic only for violated invariants that indicate programmer error, never for environmental conditions. Indexing beyond a vec length after a bounds check you proved correct, asserting a state machine transition that must hold, unimplemented! for a deliberately stubbed backend — these are invariant violations where continuing would corrupt data. Network timeouts, missing files, malformed user input, full disks, and permission denials are environmental and must return Result. If the trigger involves I/O, the network, the clock, or user data, it is environmental by definition. Write that rule in your contributing guide and enforce it in review.
expect versus unwrap is about message quality. unwrap emits a generic message with file and line, forcing responders to open the source to understand context. expect("config file must exist after install step") tells the on-call engineer what was assumed and which pipeline stage to inspect. Both still panic, so expect is damage control, not permission. Allow expect at startup for required configuration — crashing fast on a missing DATABASE_URL is kinder than serving 500s for hours — and in tests where failure should abort that test. Forbid both in request paths, background workers, and library code, where the caller deserves a Result to decide policy.
Detection is automatable. cargo clippy -- -D clippy::unwrap_used -D clippy::expect_used in CI fails builds that introduce new unwraps outside allow-listed modules. Teams typically allow expect in tests, benches, examples, and a narrow startup module via #[allow] attributes, and deny it everywhere else. Pair this with a panic hook that logs structured context: panic_hook::set_hook to capture location, thread name, and request id, then increment a counter like app_panics_total. One service cut mean time to locate panic sources from 40 minutes to under 6 minutes by adding request ids to panic logs alone.
Recovery has limits worth understanding. catch_unwind can contain a panic at a thread boundary, but it does not un-poison Mutexes and cannot resume the failed computation. Use it sparingly at isolation boundaries — a plugin runner, a per-connection handler — and treat every catch as an incident to investigate, not a control-flow tool. The 2024 guidance is unchanged: design panics out with Result, contain the rare remainder with process supervision (systemd restart, Kubernetes liveness), and never build retry logic on catch_unwind. A supervisor restarting a crashed worker is honest; a catch_unwind swallowing corruption is not.
On-call hygiene turns policy into practice. Every panic alert links to a runbook naming the owning team, the log query that isolates the site, and the config key or deploy most likely responsible. Startup panics include the binary version, the missing key, and the exact export command to fix it. Request-path panics are impossible by construction after the Clippy deny, so any occurrence triggers a postmortem, not just a restart. Track panic-free days as a service-level indicator alongside latency and error budgets. Visibility makes the zero-panic goal concrete instead of aspirational.
Apply this in your next pull request: pick one helper that returns String errors or unwraps, convert it to a typed Result with tests for each arm, and wire ? through its callers. Small migrations compound into reliable services. Measure the result with cargo test counts for new arms, clippy denies holding at zero unwraps, and log queries showing stable stems. Reviewers confirm the policy boundary handles each variant once, dashboards group cleanly by prefix, and on-call notes record the remedy beside the alert. Repeat the loop weekly until stringly errors disappear from the crate.
Manual Error Trait Impls: Display, Debug, and Backtraces
Hand-rolling the Error trait teaches what derive macros later automate. Start with a minimal enum: pub enum AppError { NotFound(String), Conflict(String), Io(std::io::Error) }. Derive Debug, since Error requires it. Implement Display with a match that writes one clear sentence per variant, including the key datum — the missing id, the conflicting email, the path that failed I/O. Implement std::error::Error, returning Some(inner) from source() for tuple variants that wrap another error and None for data-only variants. Add impl From<std::io::Error> for AppError so ? on I/O converts without ceremony. That is the complete contract, and it compiles with zero dependencies.
Display versus Debug is a deliberate split. Display is the operator-facing line: lower-case, no stack noise, one sentence with the actionable noun. Debug is the developer dump: variant name plus fields, used by anyhow-style reporters and assert output. Keep Display stable because dashboards and grep patterns depend on it; feel free to evolve Debug. A useful test asserts Display output for each variant so refactors do not silently rewrite alert strings. One team caught a breaking dashboard regex in CI because a Display test failed after a well-meaning message reword — exactly the protection these tests exist to provide.
Backtrace capture without external crates is possible through std::backtrace::Backtrace, stabilized for capture on demand. Store an optional Backtrace in variants that represent internal bugs, capturing with Backtrace::capture() at construction. Do not capture on every validation error; backtrace capture costs microseconds to milliseconds and bloats logs when applied to user errors that occur thousands of times per minute. Reserve it for 500-class failures where the extra context justifies the cost. Gate verbose chains behind RUST_BACKTRACE=1 in production so default logs stay compact and full traces are one env change away during incidents.
Size and Clone trade-offs surface quickly. Large variants (a String plus a Vec plus a backtrace) bloat every Result<T, AppError> return slot, since the Err arm reserves space even on success. Box the heavy variant payload — Box<BigContext> — to keep the enum under two or three machine words. Derive Clone only when handlers genuinely need to duplicate errors across branches; most services move errors once into a response and never clone. If Clone is required, prefer cheap clones (Arc<str> over String) in high-frequency variants. Measure with std::mem::size_of::<AppError>() in a test to prevent silent growth.
This manual foundation maps directly onto thiserror for larger codebases. The derive generates the same Display, Error, source, and From impls from attributes, removing boilerplate without changing semantics. Learn the manual form first so generated code is legible when you expand it with cargo expand. When the enum passes six variants or spans modules, adopt the derive to reduce review noise, but keep the variant design discipline identical. Automation changes keystrokes, not architecture.
Code generation awareness helps when enums grow. Derive macros expand to the same match blocks you would write by hand, so cargo expand remains readable after you adopt automation. Keep manual impls in one small module to preserve that mapping — reviewers compare generated output against the manual pattern during migration. Avoid clever macro nesting that hides variant definitions across files; explicit variant lists are easier to audit for retry policy. When builds slow, profile with cargo build --timings before blaming derives — most slowdowns trace to large dependency trees, not a single Error derive.
Apply this in your next pull request: pick one helper that returns String errors or unwraps, convert it to a typed Result with tests for each arm, and wire ? through its callers. Small migrations compound into reliable services. Measure the result with cargo test counts for new arms, clippy denies holding at zero unwraps, and log queries showing stable stems. Reviewers confirm the policy boundary handles each variant once, dashboards group cleanly by prefix, and on-call notes record the remedy beside the alert. Repeat the loop weekly until stringly errors disappear from the crate.
Anyhow Versus Thiserror: Context Chains and Migration
anyhow and thiserror solve opposite ends of the error spectrum, and confusing them produces muddy architectures. thiserror is for libraries that must expose stable, matchable error enums to callers. anyhow is for applications and CLIs that need flexible context chains and early velocity without designing an enum up front. The forward-link rule is crisp: if callers match on your error to decide policy, you need thiserror-style typed variants; if your binary only logs and exits, anyhow-style context suffices. Most production workspaces use both — libraries expose thiserror enums, binaries erase into anyhow at main.
Context attachment is anyhow's signature move. Wrapping with .context("loading tenant config") lazily prepends operator meaning to whatever low-level error occurred, producing a chain like failed to load tenant config: Caused by: No such file or directory. The std-only equivalent is manual: map_err(|e| AppError::Config { path, source: e.to_string() }). That works but allocates eagerly and severs the source chain unless you store the original. anyhow preserves the chain and defers formatting, which is why binaries prefer it during rapid iteration. When migrating to typed errors later, each .context string becomes a candidate enum variant with structured fields.
Downcasting is the escape hatch that keeps anyhow honest. anyhow::Error::downcast_ref::<std::io::Error>() recovers the typed root cause for policy decisions, and is::<T>() tests without borrowing. Use these at the few places where a binary must branch — retry on timeout, exit code 2 on config errors — while leaving the rest of the code in context-chain style. Without downcasting, anyhow binaries resort to string matching on messages, which breaks on rewording and localization. A single downcast at the policy boundary preserves both ergonomics and correctness.
Dependency and compile-time costs are real and should factor into the choice. anyhow and thiserror are small, well-maintained crates with minimal trees, adding roughly a second or two to clean builds and negligible binary size. That cost is trivial for binaries but worth weighing for no_std or deeply embedded targets, where manual Error impls avoid the dependency entirely. For standard server and CLI work in 2024, the ecosystem consensus holds: thiserror in libraries, anyhow in binaries, manual impls where dependencies are constrained. Audit with cargo tree and cargo bloat if binary budgets are tight.
Migration path matters more than initial choice. Start binaries on anyhow for speed, then extract a typed enum for the subsystem with the highest alert volume once patterns stabilize. Replace .context calls in that subsystem with variant constructors, add From impls, and keep anyhow at main as the final reporter — anyhow happily wraps your typed enum and still prints chains. This strangler pattern avoids big-bang rewrites and lets each migration pay for itself in fewer pages. Teams that invert the order — typed enums everywhere on day one — spend weeks debating variants for code paths that never fail in practice.
Choosing initial direction is reversible if you keep boundaries clean. Wrap library calls in thin adapter functions inside the binary so swapping context chains for typed variants touches one module, not fifty call sites. Log the full chain in both styles during migration to preserve triage continuity. Measure success by page volume per subsystem rather than lines changed — a migration that halves midnight pages justifies itself even if some context strings remain. Document the target state in an architecture note so new contributors extend the typed core instead of adding fresh anyhow shortcuts around it.
Apply this in your next pull request: pick one helper that returns String errors or unwraps, convert it to a typed Result with tests for each arm, and wire ? through its callers. Small migrations compound into reliable services. Measure the result with cargo test counts for new arms, clippy denies holding at zero unwraps, and log queries showing stable stems. Reviewers confirm the policy boundary handles each variant once, dashboards group cleanly by prefix, and on-call notes record the remedy beside the alert. Repeat the loop weekly until stringly errors disappear from the crate.
Error Message Design: Audience, Stability, and Safety
Error messages are user interface for operators, and most are written for the author instead of the reader. A strong message names the entity, the operation, and the remedy in one breath: tenant config /etc/app/tenants/acme.json not found; create it with app init --tenant acme. A weak message states a fact without direction: error loading config. The difference shows up in support tickets: actionable messages cut follow-up questions because responders can act without reading source. Write every message as if the reader has the log line, a pager, and five minutes — because that is exactly the situation.
Separate three audiences explicitly. End users get safe, stable, localized strings with no internals: Your upload exceeds the 10 MB limit. Operators get precise cause plus remediation: S3 PutObject denied for bucket app-uploads under role worker; grant s3:PutObject or set UPLOAD_BACKEND=local. Developers get chains, spans, and debug fields via logs and RUST_LOG, never via user responses. Leaking io paths, SQL, or user ids to end users is both a security and a clarity failure. Enforce the split with types: AppError variants carry structured fields, and separate render functions produce user_text() versus op_text().
Stability and searchability are operational requirements. Keep the leading noun phrase of each message constant across releases so dashboards can group by prefix: config missing:, auth denied:, db timeout:. Put variable data after a colon or in fields, never in the middle of the stem. Lowercase the stem, omit trailing periods in logs for cleaner aggregation, and avoid synonyms — pick denied and use it everywhere instead of alternating with forbidden, rejected, and disallowed. One service reduced duplicate alert rules by a third simply by normalizing twenty synonymous messages to six canonical stems.
Context quantity has a sweet spot. Include the entity id, the operation, and one layer of cause; omit timestamps (the logger adds them), thread ids (the runtime adds them), and full dumps of large structs. tenant=acme op=load path=/etc/app/tenants/acme.json cause=ENOENT is complete. Anything longer belongs in debug logs behind a verbose flag. For anyhow-style chains, order context from high-level intent down to root cause so readers scan top-down: failed to start API: failed to load TLS: missing file /etc/app/tls.crt. Reverse ordering forces bottom-up reconstruction and slows triage.
Test messages like contracts. Unit tests assert Display strings for user-facing variants and assert op_text contains the entity id and remedy keyword. Snapshot tests catch accidental rewords that break runbooks. A lint pass in review checks for banned patterns: unwrap in messages, raw Debug of large structs in user text, passwords or tokens interpolated into any string. These checks feel fussy until the first incident where a token leaks into logs and forces rotation across twelve services. Message design is security design, and review is the gate.
Rollout safety for message changes mirrors API changes. Announce stem renames in release notes, update Grafana queries in the same pull request, and keep old stems as aliases for one release where feasible. Canary the change on a single service and compare alert grouping before fleet-wide rollout. These steps feel heavy for log lines until you have lived through silent alert death during a reword. Treat operator-visible strings with the same semver respect as function signatures, and dashboards will survive refactors intact.
Apply this in your next pull request: pick one helper that returns String errors or unwraps, convert it to a typed Result with tests for each arm, and wire ? through its callers. Small migrations compound into reliable services. Measure the result with cargo test counts for new arms, clippy denies holding at zero unwraps, and log queries showing stable stems. Reviewers confirm the policy boundary handles each variant once, dashboards group cleanly by prefix, and on-call notes record the remedy beside the alert. Repeat the loop weekly until stringly errors disappear from the crate.
A Single unwrap() in Config Parsing Paged Checkout for 38 Minutes
ok() mapped it to None, and unwrap() panicked on all 24 pods simultaneously. The panic occurred after socket bind setup but before metrics registration, so the panic counter never incremented and alerts fired 4 minutes late on business metrics instead of instantly on process signals. Rollback required editing values and redeploying 24 pods, taking 38 minutes end to end.- Treat every unwrap outside tests as a pager waiting to happen: replace it with ok_or plus ? so missing input becomes a typed startup error with exit code and remedy instead of a thread panic with no metrics.
- Deny panics in CI with clippy::unwrap_used and clippy::expect_used, allowing expect only in tests and a single startup module; the 38-minute outage would have failed the build before merge.
- Log resolved config and probe it independently so a rename like WORKER_PORT to APP_PORT surfaces in 20 seconds on readiness rather than 4 minutes late on revenue graphs.
source() impls with rg -n 'fn source' src/error.rs; missing impls return None. Add source chaining for wrapped variants, then run RUST_BACKTRACE=1 cargo run --bin api 2>&1 | tail -n 80 to confirm full chain prints.user_text() and op_text() and retest with cargo test --lib.ok() and data loss follows| File | Command / Code | Purpose |
|---|---|---|
| src | use std::num::NonZeroU32; | Result and Option Anatomy |
| src | enum AppError { | Map and Map_Err |
| src | fn load_pool_size() -> usize { | And_Then, Or_Else, and Unwrap_Or |
| src | fn header_to_retry(v: Option<&str>) -> Result<u64, &'static str> { | Bridging Option and Result |
| src | fn parse_port(s: &str) -> Result<u16, String> { | The ? Operator |
| src | use std::fmt; | Custom Error Enums |
| src | fn must_have_port() -> u16 { | Panic Policy |
| src | use std::fmt; | Manual Error Trait Impls |
| src | use std::fmt; | Anyhow Versus Thiserror |
| src | fn user_text(code: &str) -> &'static str { | Error Message Design |
Key takeaways
Common mistakes to avoid
7 patternsCalling unwrap or expect in request handlers
Using map when and_then is required
Eager defaults with unwrap_or and ok_or in hot paths
Stringifying errors instead of typing variants
Skipping source() on wrapped error variants
source() for every wrapping variant and verify chains with RUST_BACKTRACE=1 runs.Mixing Option ? inside Result functions without bridging
Leaking internals and secrets into user-facing messages
user_text() and op_text() renderers, keep stems stable, and lint for password, token, and secret interpolation.Interview Questions on This Topic
When do you return Result versus Option, and why does the choice matter?
Frequently Asked Questions
20+ years shipping production backend systems. Drawn from code that ran under real load.
That's Core. Mark it forged?
27 min read · try the examples if you haven't