Python Pattern of the Day: Replacing Nested Try/Except with Result Objects
A practical Python pattern for taming nested error handling: model failures as values with a small Result type instead of stacking try/except blocks.
Python Pattern of the Day: Replacing Nested Try/Except with Result Objects
Nested try/except blocks are the silent killer of Python codebases. You start with one defensive guard, add a second when the API surface grows, and six months later a 40-line function has three levels of indentation where the only thing happening is "did this step fail." Reviewers stop reading. Bugs hide in the inner except that swallowed an error the outer handler was supposed to see.
This pattern replaces the nesting with a Result object \u2014 a tiny value type that carries either a success payload or a failure reason. It is borrowed from Rust's Result<T, E> and Haskell's Either, but it works fine in plain Python with no dependencies.
The pain \u2014 what nested try/except actually looks like
Here is a function that fetches a user, loads their profile, and applies a rate-limit check. Each step can fail differently, so the naive version stacks handlers:
def get_user_profile(user_id: str) -> dict | None:
try:
user = db.fetch_user(user_id)
try:
profile = profile_service.load(user.id)
try:
rate_limiter.check(user.id)
return {"user": user, "profile": profile}
except RateLimitExceeded as e:
logger.warning("rate limited: %s", e)
return None
except ProfileNotFound:
logger.error("profile missing for %s", user.id)
return None
except UserNotFound:
logger.error("user %s not in db", user_id)
return None
Three levels deep. The happy path is buried at the bottom. Each except returns None, so the caller cannot tell which failure happened without reading the logs. Adding a fourth check means another indent.
The pattern \u2014 Result as a value
Instead of raising and catching, each step returns a Result. A Result is either Ok(value) or Err(reason). The caller chains them with a small helper, and the function stays flat:
from dataclasses import dataclass
from typing import Generic, TypeVar, Callable
T = TypeVar("T")
E = TypeVar("E")
@dataclass(frozen=True)
class Ok(Generic[T]):
value: T
is_ok: bool = True
@dataclass(frozen=True)
class Err(Generic[E]):
reason: E
is_ok: bool = False
Result = Ok[T] | Err[E]
def bind(result: Result[T, E], fn: Callable[[T], Result]) -> Result:
if isinstance(result, Err):
return result
return fn(result.value)
That is the entire abstraction \u2014 two dataclasses and one helper. Now each step becomes a function returning Result:
def fetch_user(user_id: str) -> Result[User, str]:
user = db.find(user_id)
if user is None:
return Err(f"user {user_id} not in db")
return Ok(user)
def load_profile(user: User) -> Result[Profile, str]:
profile = profile_store.get(user.id)
if profile is None:
return Err(f"profile missing for {user.id}")
return Ok(profile)
def check_rate_limit(user: User) -> Result[None, str]:
if rate_limiter.is_exceeded(user.id):
return Err("rate limited")
return Ok(None)
Each function has one path in and two paths out. No exceptions to chase across module boundaries.
Wiring it together \u2014 the flat composition
The caller chains the steps. There is no nesting because bind short-circuits on the first Err:
def get_user_profile(user_id: str) -> Result[dict, str]:
user_result = fetch_user(user_id)
profile_result = bind(user_result, load_profile)
rate_result = bind(user_result, check_rate_limit)
if isinstance(rate_result, Err):
return rate_result
if isinstance(profile_result, Err):
return profile_result
return Ok({"user": user_result.value, "profile": profile_result.value})
Reads top to bottom. The error reason is preserved in the return value, so callers can inspect it without grepping logs. If you want even more flow, walrus + early return collapses the tail:
def get_user_profile(user_id: str) -> Result[dict, str]:
if isinstance(user := fetch_user(user_id), Err):
return user
if isinstance(profile := load_profile(user.value), Err):
return profile
if isinstance(rate := check_rate_limit(user.value), Err):
return rate
return Ok({"user": user.value, "profile": profile.value})
Three guard clauses, zero indentation. Static type checkers like mypy and pyright narrow user.value correctly inside each branch because Ok and Err are distinct types.
Result vs exceptions \u2014 when to use which
This is not a campaign to ban exceptions. They have a job. Use exceptions for unexpected failures: a malformed config file at startup, a corrupted internal invariant, a programming error you want to crash on. Use Result for expected failures: validation errors, lookups that miss, downstream services returning 404. The line is whether the caller is morally obligated to handle it.
A useful heuristic: if you find yourself catching the same exception in three different call sites, that exception is really a return value in disguise. Promote it to a Result. Around 80% of nested try/except I see in code review fits this category.
The downside is verbosity at call sites \u2014 you do write if isinstance(... Err) more often than try:. In a codebase with deep call chains, that overhead adds up. Languages like Rust solve this with the ? operator; Python does not have one, so you pay the cost in lines. The trade is worth it when the alternative is three-level nesting.
Performance \u2014 is this slower than exceptions?
A common worry is allocation cost. Creating an Ok or Err dataclass on every call sounds expensive compared to a hot-path function that almost never raises. In practice the difference is around 200ns per call on CPython 3.12 \u2014 measurable in a tight loop, invisible in any function doing real I/O. Exceptions in Python are NOT free either: raising and catching costs roughly 1-3\u03bcs depending on stack depth, so a function that fails 10% of the time pays more for try/except than for Result.
If allocation truly matters, cache singleton Err instances for common reasons (USER_NOT_FOUND = Err("user not found")) and reuse them. The frozen=True dataclass makes this safe.
Where to start
Pick one module with the worst try/except nesting in your codebase. Convert it to Result and see how the call sites read. Do not try to convert the whole codebase at once \u2014 Result and exceptions coexist fine, and the boundary between them tends to settle naturally at module edges.
For a deeper treatment of this pattern in Python, the returns library (https://github.com/dry-python/returns) ships a production-ready Result with do-notation, Maybe, and IOResult variants. The Python typing docs (https://docs.python.org/3/library/typing.html) cover the TypeVar + Generic machinery you need to make your own type-safe version. Mypy's tagged-union narrowing rules (https://mypy.readthedocs.io/en/stable/type_narrowing.html) are what makes isinstance(x, Err) actually work without casts.
References: