datarekha

Errors & Exceptions

try/except done right — catch specific errors, chain context, raise custom types, and know when NOT to use exceptions at all.

10 min read Intermediate Python Lesson 17 of 41

What you'll learn

  • try / except / else / finally and what each clause is for
  • The exception hierarchy, and why a bare except: is a footgun
  • Custom exceptions and raise ... from ... for preserving the cause
  • EAFP vs LBYL, and when NOT to use exceptions at all

Before you start

Exceptions are how expected, recoverable failures travel up the call stack until some caller is ready to deal with them. Handled well, they keep your code linear and readable — the happy path reads top to bottom, and the error handling sits to one side. Handled badly, they become a place where real bugs go to hide. The difference between the two comes down to a few habits, and they are worth getting right early.

try / except / else / finally — all four clauses

Most code uses only try and except, but the full statement has four parts, each with a distinct job:

def parse_age(text):
    try:
        age = int(text)
    except ValueError:
        print("  not a number")
        return None
    else:
        # runs ONLY if the try block raised nothing
        print(f"  parsed: {age}")
    finally:
        # ALWAYS runs — success, failure, even a return
        print("  (finally)")
    return age

parse_age("28")
print("---")
parse_age("oops")
  parsed: 28
  (finally)
---
  not a number
  (finally)

Read the two runs against the clauses. try holds the risky line; except handles one specific failure; else runs only when the try succeeded (so keep the try itself small — just the line that might fail); and finally runs no matter what, which is why “(finally)” prints on both the success and the failure path, even though the failure path returned early. The else is for “this only makes sense if the try worked”, and finally is the by-hand version of the cleanup a context manager gives you for free.

Catch specific. Never bare.

The single most important habit is to catch the narrowest exception that makes sense. Watch three versions of the same function, from dangerous to correct:

def bad(text):
    try:
        return int(text)
    except:                         # bare except — catches EVERYTHING
        return None                 # including Ctrl-C, sys.exit(), and your bugs

def loose(text):
    try:
        return int(text)
    except Exception:               # all "ordinary" exceptions
        return None                 # still hides typos and logic bugs

def good(text):
    try:
        return int(text)
    except ValueError:              # exactly the failure we anticipate
        return None

for fn in (bad, loose, good):
    print(fn.__name__, "->", fn("42"), fn("nope"))
bad -> 42 None
loose -> 42 None
good -> 42 None

All three give the same answers today — and that is exactly the trap. The difference shows up the day a typo inside the try raises a NameError: good lets that bug surface loudly, while bad and loose quietly swallow it and return None, sending you on a long debugging hunt.

The exception hierarchy

Every exception inherits from BaseException. The shape worth carrying in your head is this:

BaseExceptionSystemExitdon’t catchKeyboardInterruptCtrl-C, don’t catchExceptioncatch this or subclassesValueErrorTypeErrorOSErrorFileNotFoundError,PermissionError, …ArithmeticErrorZeroDivisionErrorLookupErrorKeyError, IndexError
The Python exception tree — catch Exception or a narrower subclass; leave the dashed ones alone.

Catching Exception is the broadest safe catch — it covers ordinary errors but leaves the dashed ones (SystemExit, KeyboardInterrupt) free to do their job. Catching BaseException would intercept Ctrl-C, which is almost never what you want.

Custom exceptions

When the built-in types do not convey your intent, define your own — they are just classes inheriting from Exception. A small hierarchy lets callers catch broadly or narrowly, and attaching data to the exception spares handlers from parsing strings:

class PaymentError(Exception):
    """Base class for payment failures."""

class InsufficientFunds(PaymentError):
    def __init__(self, balance, required):
        super().__init__(f"need {required}, have {balance}")
        self.balance = balance
        self.required = required

class CardDeclined(PaymentError):
    pass

def charge(balance, amount):
    if amount > balance:
        raise InsufficientFunds(balance, amount)
    return balance - amount

try:
    charge(50, 200)
except InsufficientFunds as e:
    print(f"  declined: {e}")
    print(f"  short by ${e.required - e.balance}")
except PaymentError:
    print("  some other payment issue")
  declined: need 200, have 50
  short by $150

Because InsufficientFunds carries .balance and .required as real attributes, the handler computed “short by $150” directly — no scraping numbers out of a message string. And because it is a subclass of PaymentError, a caller can catch the specific case first and a general payment problem second.

Chaining — raise … from …

When you catch a low-level error and re-raise a higher-level one, use raise ... from ... to keep the original cause attached. The traceback then shows both errors, which is the difference between a debuggable failure and a mystery:

class ConfigError(Exception):
    pass

def load_port(s):
    try:
        return int(s)
    except ValueError as e:
        raise ConfigError(f"PORT must be an integer, got {s!r}") from e

try:
    load_port("ninety")
except ConfigError as e:
    print("ConfigError:", e)
    print("Original cause:", repr(e.__cause__))
ConfigError: PORT must be an integer, got 'ninety'
Original cause: ValueError("invalid literal for int() with base 10: 'ninety'")

The from e stores the original ValueError as __cause__, and a real traceback would print “The above exception was the direct cause of the following exception” between the two. Throwing away the cause — re-raising without from — is how you turn a five-minute fix into an afternoon of guessing.

Re-raising, and exception groups

Sometimes you only want to annotate — log the failure, then let the same exception continue on its way. A bare raise inside an except block does exactly that, preserving the original traceback:

try:
    do_thing()
except Exception:
    logger.exception("do_thing failed")  # logs with the full traceback
    raise                                # re-raises the SAME exception, intact

Prefer bare raise over raise e — the bare form keeps the original traceback and avoids the risk of accidentally re-binding e. And when several things can fail at once — a batch of requests, a set of async tasks — Python 3.11 added ExceptionGroup, raised when you have collected multiple failures, and caught with the matching except* syntax. It is niche, but you will meet it in modern asyncio code.

A real handler — retrying a rate limit

Here is the pattern real SDKs (Stripe, OpenAI, Twilio) use: a custom exception that carries a structured field, so the retry logic reads the field instead of parsing the message. The fake API is rigged to rate-limit twice and then succeed, so the output is exact:

import time

class RateLimitError(Exception):
    def __init__(self, retry_after):
        super().__init__(f"rate limited, retry after {retry_after}s")
        self.retry_after = retry_after

state = {"calls": 0}

def call_api():
    state["calls"] += 1
    if state["calls"] < 3:           # first two calls are rate-limited
        raise RateLimitError(retry_after=0.05)
    return {"ok": True}

def safe_call(max_attempts=4):
    for attempt in range(1, max_attempts + 1):
        try:
            return call_api()
        except RateLimitError as e:
            if attempt == max_attempts:
                raise                # out of retries — let it propagate
            print(f"  attempt {attempt}: backing off {e.retry_after}s")
            time.sleep(e.retry_after)

print("got:", safe_call())
  attempt 1: backing off 0.05s
  attempt 2: backing off 0.05s
got: {'ok': True}

The retry loop never inspects the error message — it reads e.retry_after straight off the exception. That is the payoff of putting structured data on a custom exception.

EAFP versus LBYL — and when not to use exceptions

Two styles recur in Python. EAFP — “easier to ask forgiveness than permission” — tries the operation and handles the failure. LBYL — “look before you leap” — checks preconditions first. Python leans EAFP, but the right tool depends on the case:

# EAFP — try, then handle failure (often idiomatic in Python)
try:
    value = config["port"]
except KeyError:
    value = 8000

# LBYL — check first (idiomatic in C / Java)
if "port" in config:
    value = config["port"]
else:
    value = 8000

# Best for THIS case — dict.get with a default
value = config.get("port", 8000)

This points at the most important judgement call of all: do not use exceptions for ordinary control flow. A key that is merely “expected to be missing sometimes” is not exceptional — reach for dict.get(), not try/except KeyError. Save try/except for genuinely exceptional conditions, and for racy situations where the state could change between a check and the use that follows it. Exceptions in Python are fast, but using them as disguised if-statements makes code hard to read.

In one breath

  • try/except/else/finally: risky code, the handler, the success-only branch, and the always-runs cleanup.
  • Catch the narrowest exception; never bare except: (it swallows Ctrl-C and your bugs).
  • Define custom exceptions with attributes so handlers read data, not message strings.
  • raise New(...) from e preserves the original cause; bare raise re-raises intact.
  • Don’t use exceptions for normal branching — dict.get() over try/except KeyError.

Practice

Quick check

0/3
Q1Why is a bare `except:` almost always wrong?
Q2What does `raise ConfigError('bad') from e` do that `raise ConfigError('bad')` does not?
Q3Which is the idiomatic Python way to look up a missing config key with a default?

What’s next

You have the language itself. Time to spread code across files — modules and packages, and how Python’s import system finds them.

Sign in to track your progress

Completed lessons, your XP, level, and streak save to your account — it's free and takes a few seconds.

Practice this in an interview

All questions
How do you define and raise custom exceptions in Python?

Subclass Exception (not BaseException) to create a custom exception. Give it a clear name, optionally store structured data in __init__, and place it in a dedicated exceptions module. Raise it with raise and catch it with except, narrowing to your specific type before broader ones.

What do the else and finally clauses of a try block do, and when does finally NOT run?

The else clause runs only when the try block exits without raising an exception — it lets you separate success-path code from the guarded block. The finally clause runs in every case: after normal exit, after an exception (caught or uncaught), and after a return or break inside try or except. The only situations where finally is skipped are a hard interpreter crash (SIGKILL, os._exit, power loss).

How does Python's mutable vs immutable distinction affect function arguments and default values?

Python passes references to objects, so a mutable argument (list, dict, set) can be modified inside a function and the change is visible to the caller. An immutable argument (int, str, tuple) cannot be mutated in place, so rebinding the local name only affects the local scope. The most common trap is using a mutable object as a default argument value, which is shared across all calls.

What is a context manager and how does the with statement work?

A context manager is any object that implements __enter__ and __exit__. The with statement calls __enter__ on entry and __exit__ on exit — guaranteed, even if an exception is raised. This makes with the idiomatic way to manage resources like files, locks, and database transactions without leaking them.

Related lessons

Explore further

Cheat sheets
Skip to content