Error Handling and Exceptions

try/except/else/finally, raising and re-raising, custom exception classes, and exception chaining with raise...from.

Why exceptions instead of error codes

Python signals a runtime error by raising an exception — an object that propagates up the call stack, unwinding one function call after another, until something explicitly handles it or the program terminates with a traceback. This is a fundamentally different style from a C-like language returning a special error code that the caller has to remember to check: an exception can't be silently ignored, and the code path for "everything went fine" stays clean and uncluttered by error-checking on every single line.

Python
def divide(a, b):
    return a / b

print(divide(10, 2))   # 5.0
print(divide(10, 0))   # ZeroDivisionError: division by zero

Left unhandled, that ZeroDivisionError propagates all the way up and crashes the program with a traceback showing exactly where it happened. Handling it — deciding what "recovering" even means for that specific error — is what try/except is for.

try / except

Python
def divide(a, b):
    try:
        result = a / b
    except ZeroDivisionError:
        print("Cannot divide by zero")
        return None
    return result

print(divide(10, 2))   # 5.0
print(divide(10, 0))   # Cannot divide by zero -> None

Catch the most specific exception type that applies, not a bare except: — a bare except (or except Exception:) swallows errors you never intended to catch, including genuine bugs, making them far harder to diagnose later:

Python
def parse_config_value(raw):
    try:
        return int(raw)
    except ValueError:
        print(f"'{raw}' is not a valid integer, defaulting to 0")
        return 0

A single try can catch several different exception types, either with a tuple in one except or with multiple except clauses handled differently:

Python
def load_user(user_id):
    try:
        return database.fetch(user_id)
    except (ConnectionError, TimeoutError):
        print("Network issue — retrying later")
        return None
    except KeyError:
        print(f"No user with id {user_id}")
        return None

Python checks except clauses top to bottom and runs the first one that matches, so put more specific exception types before more general ones (a ConnectionError clause after a catch-all except Exception clause would never run, since the general one already caught it).

else and finally

A try statement supports two more optional clauses beyond except:

  • else — runs only if the try block completed with no exception raised. It's a place to put code that should run on success but that you don't want accidentally caught by the except clauses above it (which are only watching the try block itself).
  • finally — always runs, whether an exception was raised, handled, or not raised at all — even if the try/except block returns or re-raises. It's the right place for cleanup that absolutely must happen (closing a file handle, releasing a lock, logging that an operation finished).
Python
def read_config(path):
    try:
        f = open(path)
    except FileNotFoundError:
        print(f"{path} not found, using defaults")
        return {}
    else:
        # only reached if open() succeeded — no exception to accidentally catch here
        contents = f.read()
        return parse(contents)
    finally:
        # always runs — whether open() failed, parse() failed, or everything succeeded
        print("read_config finished")

Raising exceptions yourself

raise triggers an exception on purpose — validating input and refusing to continue with bad data is the most common reason:

Python
def set_age(age):
    if age < 0:
        raise ValueError(f"Age cannot be negative, got {age}")
    return age

set_age(-5)   # ValueError: Age cannot be negative, got -5

A bare raise with no expression, used only inside an except block, re-raises the exception currently being handled — useful for logging or partial cleanup before letting the original error continue propagating unchanged:

Python
def process_payment(amount):
    try:
        charge_card(amount)
    except PaymentError:
        logger.error(f"Payment of {amount} failed")
        raise   # re-raise the same PaymentError, unchanged, after logging it

Custom exception classes

Python's built-in exceptions (ValueError, KeyError, TypeError, and dozens more) cover generic situations, but real applications benefit from their own exception types that describe a specific business-level failure. A custom exception is just a class inheriting from Exception (or a more specific built-in exception, when that relationship is genuinely accurate):

Python
class InsufficientFundsError(Exception):
    """Raised when a withdrawal would overdraw an account."""

    def __init__(self, balance, amount):
        self.balance = balance
        self.amount = amount
        super().__init__(
            f"Cannot withdraw {amount}: balance is only {balance}"
        )


class BankAccount:
    def __init__(self, balance=0):
        self.balance = balance

    def withdraw(self, amount):
        if amount > self.balance:
            raise InsufficientFundsError(self.balance, amount)
        self.balance -= amount


account = BankAccount(balance=100)
try:
    account.withdraw(250)
except InsufficientFundsError as e:
    print(e)                 # Cannot withdraw 250: balance is only 100
    print(e.balance, e.amount)  # 100 250 — extra structured data on the exception itself

Attaching structured data (balance, amount above) directly onto the exception, rather than only a formatted message string, lets calling code make decisions based on the failure — showing a specific UI message, retrying with a smaller amount, logging structured fields — without parsing text back out of an error message.

Building a small hierarchy of related custom exceptions under one common base class lets calling code catch broadly or narrowly, its choice:

Python
class ApplicationError(Exception):
    """Base class for all of this application's own exceptions."""

class InsufficientFundsError(ApplicationError):
    pass

class AccountFrozenError(ApplicationError):
    pass

try:
    account.withdraw(250)
except ApplicationError as e:
    # catches InsufficientFundsError, AccountFrozenError, or any future subclass —
    # without needing to list every one of them individually
    print(f"Transaction failed: {e}")

Exception chaining with raise ... from

It's common to catch a low-level exception and raise a different, more meaningful one in its place — translating a generic KeyError into a domain-specific ConfigurationError, for instance. Doing this with a plain raise still shows the original exception in the traceback (as "the above exception was the direct cause"), but raise NewError(...) from original_error makes that causal relationship explicit and intentional rather than incidental:

Python
class ConfigurationError(Exception):
    pass

def load_setting(config, key):
    try:
        return config[key]
    except KeyError as e:
        raise ConfigurationError(f"Missing required setting: {key}") from e
Plaintext
Traceback (most recent call last):
  File "app.py", line 5, in load_setting
    return config[key]
KeyError: 'database_url'

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "app.py", line 12, in <module>
    load_setting(config, "database_url")
  File "app.py", line 7, in load_setting
    raise ConfigurationError(f"Missing required setting: {key}") from e
ConfigurationError: Missing required setting: database_url

Both tracebacks are printed, which is exactly the point — someone debugging this later sees both "what the application-level error was" (ConfigurationError) and "what actually caused it underneath" (KeyError), instead of the original root cause being silently discarded. raise NewError(...) from None is the deliberate opposite — it explicitly suppresses the original exception's traceback entirely, for the rare case where the underlying cause is genuinely irrelevant noise.

Common mistakes

  • Using a bare except: (or except Exception:) that catches everything, including bugs like a TypeError from a typo — masking real problems instead of handling the specific failure you actually anticipated.
  • Swallowing an exception silently (an empty except SomeError: pass) with no logging at all — the failure vanishes with no trace, making a production issue nearly impossible to diagnose later.
  • Raising a new exception from inside an except block with a plain raise NewError(...) instead of raise NewError(...) from e — it still works, but discards the clear, deliberate link to the original underlying cause in the traceback.
  • Putting cleanup code that must always run inside the try block after the risky operation, instead of in finally — if the risky operation raises, that cleanup code is skipped entirely.

Interview questions

Q: What's the difference between except's else clause and just putting that code after the try/except block? Code in else only runs if the try block raised no exception at all, and — crucially — it isn't covered by the except clauses above it, so an exception raised inside else itself won't be accidentally caught as if it came from the original try block. Code placed after the entire try/except statement runs regardless of whether an exception was raised and handled, which is a meaningfully different guarantee.

Q: When would you write a custom exception class instead of raising a built-in one like ValueError? When the failure represents a specific, meaningful condition in your application's domain (InsufficientFundsError, AccountFrozenError) rather than a generic "bad value" — a custom exception lets calling code catch that specific condition precisely, attach structured data relevant to it (like the account's balance), and build a hierarchy of related exceptions under one common base class that calling code can catch at whatever level of specificity it needs.