DEV Community

Ian Johnson
Ian Johnson

Posted on

Use exceptions for (wait for it) exceptional things

You know the code. A function tries to do something, something goes wrong, and you see:

print(f"Error: could not load config from {path}")
sys.exit(1)
Enter fullscreen mode Exit fullscreen mode

Or this:

result = fetch_user(user_id)
if result is None:
    return None
profile = fetch_profile(result.id)
if profile is None:
    return None
...
Enter fullscreen mode Exit fullscreen mode

Or, in a slightly more sophisticated codebase, the function returns a tuple of (value, error) or a dict like {"ok": False, "error": "..."} and every caller has to remember to check it.

What these patterns share is that they're working hard to avoid using the feature the language built specifically for this situation: exceptions.

The avoidance is real

I don't have data, just years of reading other people's code. But the pattern is consistent: a lot of developers will reach for almost anything before raising an exception. They'll print and continue. They'll die or os.exit. They'll return None and propagate it up by hand through six layers of caller. They'll catch an exception just to convert it into a boolean. They'll silently swallow it with a bare except. They'll handle the error inline, awkwardly, at exactly the layer that has no idea what to do about it.

I don't fully understand why. Some of it is taste — Go made error-as-value fashionable, Rust made Result<T, E> rigorous, and some of that vibe leaked into communities where exceptions are the idiomatic choice. Some of it is fear: exceptions feel like spooky action at a distance because they unwind the stack. Some of it is just forgetting they exist. Most languages teach you try/catch once in an intro tutorial and then never bring it up again.

But exceptions exist for a reason, and the reason is good.

What exceptions actually buy you

The job of an exception is to separate the happy path from the error path. In the happy path, you write what the code is supposed to do, in the order it's supposed to do it, without interrupting yourself every two lines to check whether the last step worked. In the error path, you write what to do when things go wrong — but you write it once, at the layer that's actually equipped to handle the problem, not at every intermediate layer that just happens to be on the call stack.

Compare:

def load_user_dashboard(user_id):
    user = fetch_user(user_id)
    if user is None:
        return None
    profile = fetch_profile(user.id)
    if profile is None:
        return None
    recent = fetch_recent_activity(user.id)
    if recent is None:
        return None
    return build_dashboard(user, profile, recent)
Enter fullscreen mode Exit fullscreen mode

with:

def load_user_dashboard(user_id):
    user = fetch_user(user_id)
    profile = fetch_profile(user.id)
    recent = fetch_recent_activity(user.id)
    return build_dashboard(user, profile, recent)
Enter fullscreen mode Exit fullscreen mode

The second version reads like what the function does. If any step fails, the exception bubbles up to wherever you decided to catch it, probably a single try/except in the request handler that knows how to translate a UserNotFound or a DatabaseUnavailable into the right response. The intermediate layers are blissfully unaware that anything can go wrong, because they have nothing useful to contribute when something does.

That's the whole point. Errors propagate themselves. You catch them where the context to handle them exists. Everywhere else, your code gets to be about the thing it's actually about.

And print, die, and bare exit?

These are worse than no error handling — they're error handling pretending to be helpful. A print followed by exit(1) decides, on behalf of every possible caller, that the right response to a problem is to dump a message to stderr and kill the entire process. That's fine in a one-off script. In a library, a server, a long-running job, or anything called from another piece of code, it's a small disaster. The caller wanted to catch the error and retry, or log it with structure, or surface it to a user, or fall back to a default. Instead, the process died and there's a string somewhere in stderr.

Raising an exception is the polite, composable thing to do. It says: something went wrong here, in this specific way; whoever called me can decide what to do about it.

The "exceptional" part of the title

The other half of the joke is that exceptions are for exceptional things: situations that genuinely prevent the function from doing its job. They are not a control flow mechanism for ordinary, expected outcomes.

A user not being found by ID inside a system that just created them: exceptional. A user not being found when you're looking them up by an email someone typed into a form: expected. That's a normal outcome of the operation, and it should probably be None, a Maybe, or a domain-specific result type. Form validation failing: expected. A network blip while the database is restarting: exceptional. The file you were promised exists not existing: depends on the contract.

The rough test is: when this happens, can the immediate caller plausibly do something sensible about it as part of its normal logic? If yes, it's an expected outcome. Model it in the return type. If no, and the function genuinely couldn't fulfill its contract, raise.

Used this way, exceptions stay rare, which keeps them meaningful. When you see a try/except in well-written code, it's a flag: something here can really go wrong, and someone thought about what to do about it. Used for ordinary control flow, they become noise, and the signal is lost.

The short version

Don't print and pray. Don't die. Don't smuggle errors through return types out of habit when the language has a perfectly good mechanism for them. Don't catch exceptions just to convert them into something less expressive. And don't use them for things that aren't actually exceptional.

Raise when your function genuinely can't do its job. Catch where you actually know what to do. Let the happy path be a happy path. That's what exceptions are for.

Top comments (1)

Collapse
 
godaddy_llc_4e3a2f1804238 profile image
GoDaddy LLC

Really solid breakdown of a problem most teams quietly normalize instead of fixing. The “return None and pray the caller remembers” pattern has probably generated more accidental bugs than caffeine has generated commits ☕😂
I also liked the distinction between expected outcomes and actual failures — that nuance gets lost in a lot of beginner discussions around exceptions.
Good exception handling makes the happy path readable and keeps recovery logic where it actually belongs.
And honestly, print("something broke") followed by exit(1) is basically the software equivalent of pulling the fire alarm because the Wi-Fi disconnected for 3 seconds.
Clean code isn’t code with zero exceptions — it’s code where failures propagate intentionally instead of emotionally.