Foundations

Actuality

This article is still relevant because the choice between exceptions and conditional checks appears in almost every Python codebase. The surface-level examples are usually small, but the real consequences appear when code starts to cross service boundaries, validation layers, and application-level orchestration.

The important question is not whether exceptions are shorter than if/else, but where each style keeps the surrounding code clearer.

Thesis

For simple local checks, explicit conditionals are often perfectly fine. But once failure has to travel through several levels of the call stack, exceptions usually produce cleaner interfaces than returning sentinel values such as None and checking them repeatedly.

In Python terms, this is the familiar contrast between LBYL (Look Before You Leap) and EAFP (Easier to Ask Forgiveness than Permission). The more the code behaves like a workflow with distinct failure modes, the more EAFP tends to fit.

Core concepts

Three distinctions matter here.

Invalid state versus expected branching. Use conditionals when both branches are ordinary business flow. Use exceptions when something has gone wrong and the caller may not be the right layer to decide what to do next.

Local handling versus propagated failure. Returning None or another sentinel may be fine when the caller can handle the case immediately. It becomes noisy when the same failure must cross many functions.

Interface clarity versus defensive plumbing. Exception-based code often lets the happy path stay explicit, while conditional code can accumulate nested checks and union types.

Limits

Exceptions are not always the right answer.

  • They can be overused for ordinary branching.
  • They can hide possible outcomes if the exception types are vague.
  • They require discipline in naming and handling.
  • They can confuse less experienced readers if the codebase mixes several styles inconsistently.

The point is not that EAFP always wins. The point is that exceptions become more compelling as failure moves farther away from the place where it can be handled meaningfully.

Implementation and examples

A small example

At a very small scale, the difference is not dramatic. Suppose we read an age from input and convert it to an integer.

# conditional version
def read_age() -> int | None:
    user_input = input("Enter the age: ")

    if user_input.isnumeric():
        return int(user_input)

    print("Please enter a valid integer value.")
    return None


# exception version
def read_age() -> int | None:
    user_input = input("Enter the age: ")

    try:
        return int(user_input)
    except ValueError:
        print("Please enter a valid integer value.")
        return None

Here the two versions are close enough that style preference may dominate. The exception version is not obviously superior. That is exactly why this topic is easy to underestimate.

Why the trade-off becomes visible later

The difference becomes clearer when the code is no longer a single function but a chain of operations.

Imagine a cache layer backed by a third-party Redis client. The application wants to fetch a user, convert the payload into a model, and then verify that the user is an adult.

The fixed external and domain pieces look like this:

from dataclasses import dataclass


class RedisClient:
    def fetch(self, key: str) -> str | None:
        """Third-party logic."""
        ...


@dataclass
class User:
    name: str
    age: int

Now the question becomes architectural: should missing data and failed validation travel upward as return values, or as exceptions?

Conditional style across layers

With a conditional style, each layer tends to return a value or None, and the next layer must keep checking.

import json


class Cache:
    client = RedisClient()

    @classmethod
    def get(cls, key: str) -> dict | None:
        if data := cls.client.fetch(key):
            return json.loads(data)
        return None


def is_adult(user: User) -> bool:
    return user.age >= 18


def get_adult(user_id: str) -> User | None:
    user_payload = Cache.get(user_id)
    if user_payload is None:
        print(f"Cannot find item {user_id} in the cache.")
        return None

    user = User(**user_payload)
    if not is_adult(user):
        print(f"User with id={user_id} is not an adult.")
        return None

    return user


def main() -> None:
    user_id = "5570260f-17f7-4c76-bc63-3fc858ccb498"
    user = get_adult(user_id)

    if user is None:
        return

    # proceed with user

This works, but the style has a cost.

  • every layer must remember to check for None
  • function signatures widen into union types
  • the happy path gets mixed with defensive plumbing
  • callers far away from the original failure still need to care about it

Exception style across layers

With exceptions, the intermediate functions can keep their main interfaces narrow and let failure travel until it reaches an appropriate handling boundary.

import functools
import json
from collections.abc import Callable


class UserNotFoundError(Exception):
    def __init__(self, user_id: str) -> None:
        super().__init__(f"Cannot find user with id={user_id}.")


class UserIsMinorError(Exception):
    def __init__(self, user_id: str) -> None:
        super().__init__(f"User with id={user_id} is not an adult.")


def handle_errors(func: Callable[..., None]) -> Callable[..., None]:
    @functools.wraps(func)
    def inner(*args, **kwargs) -> None:
        try:
            func(*args, **kwargs)
        except (UserNotFoundError, UserIsMinorError) as error:
            print(error)

    return inner


class Cache:
    client = RedisClient()

    @classmethod
    def get(cls, key: str) -> dict:
        if data := cls.client.fetch(key):
            return json.loads(data)
        raise UserNotFoundError(key)


def check_is_adult(user: User, user_id: str) -> None:
    if user.age < 18:
        raise UserIsMinorError(user_id)


def get_adult(user_id: str) -> User:
    user_payload = Cache.get(user_id)
    user = User(**user_payload)
    check_is_adult(user, user_id)
    return user


@handle_errors
def main() -> None:
    user_id = "5570260f-17f7-4c76-bc63-3fc858ccb498"
    user = get_adult(user_id)
    # proceed with user

Here the main flow reads more directly:

  • get the payload
  • build the model
  • validate the user
  • return the result

The failure logic still exists, but it is expressed as failure rather than encoded as alternate return values at every step.

Why EAFP often scales better

The strongest argument for EAFP is not shorter code. It is separation of concerns.

A cache method should know how to report absence. A validation function should know how to report invalid domain state. The application boundary should decide how those failures are presented to the user, logged, or transformed into HTTP responses, CLI messages, and so on.

Exceptions fit that layering well because they let each function keep a narrow success-oriented contract while still preserving rich failure information.

This also tends to interact better with types. A function returning User is easier to compose than a function returning User | None when every caller must remember which kind of absence None represents.

When conditionals are still better

None of this means that conditional checks should disappear.

Use conditionals when:

  • both branches are ordinary expected flow
  • the caller can handle the alternative immediately
  • the code is local and simple enough that exceptions add ceremony
  • you are validating normal input choices rather than reporting deeper failure

A useful rule of thumb is that if/else fits branching, while exceptions fit failed assumptions or failed operations.

Summary

The difference between EAFP and LBYL is small in toy examples and significant in layered code. Once a failure must pass through several functions, exceptions often keep interfaces cleaner than returning None and checking it everywhere.

That does not make exceptions universally better. It means they are often the more natural tool when the code has a clear happy path and several meaningful failure modes.

Recommendations

  • use conditionals for ordinary local branching
  • use exceptions when failure must propagate across layers
  • name exception types precisely so they document intent
  • keep exception handling near application boundaries when possible
  • avoid returning None through many layers when the failure deserves a real meaning

A useful Python exception is not a shortcut around control flow. It is a way to keep the success path readable while giving failure a precise shape.