Errors, exceptions and files · about 40 min

Raising exceptions and defining your own

Why this matters

So far exceptions have happened to you. Now you will raise them yourself. When your function receives input it cannot work with, the worst thing it can do is carry on and return something wrong, or a "magic" value like -1 that the caller forgets to check. Raising an exception stops the bad data immediately, at the point where it was detected, with a message that explains exactly what was wrong. Defining your own exception types lets callers react precisely to your failures. This is how every serious library reports errors, and it is how your own programs will stay debuggable as they grow.

raise

The raise statement takes an exception object (or class) and starts the same unwinding process you saw when Python raises an error itself:

def set_volume(level):
    if not 0 <= level <= 10:
        raise ValueError(f"volume must be 0-10, got {level}")
    print("volume set to", level)

set_volume(7)
set_volume(42)
volume set to 7
Traceback (most recent call last):
  File "/work/main.py", line 7, in <module>
    set_volume(42)
  File "/work/main.py", line 3, in set_volume
    raise ValueError(f"volume must be 0-10, got {level}")
ValueError: volume must be 0-10, got 42

ValueError(...) creates the exception object (it is a class call, like int("3")); raise raises it. The function stops at that line: nothing after the raise runs, and no value is returned. raise ValueError without parentheses also works (Python creates the object for you), but always include a message: the person reading the traceback will thank you.

Which type to raise

Reuse built-in types when they fit; callers already know them:

SituationRaise
Argument has the wrong typeTypeError
Right type, unacceptable valueValueError
Missing key in a mapping-like lookupKeyError
Index out of rangeIndexError
Feature intentionally not implemented yetNotImplementedError
Something in the environment failed (files, OS)OSError subclasses, usually raised for you

A good message says what was expected and what was received: "expected a date like 2026-11-01, got '11/01'" beats "bad date".

Raise early, at the boundary

Validate inputs at the start of a function, before doing any work. The error then points at the real cause, and you never leave things half done:

def average(nums):
    if not nums:
        raise ValueError("average() of an empty list")
    return sum(nums) / len(nums)

Compare the alternative, ZeroDivisionError: division by zero from inside the formula: technically true, but it says nothing about what the caller did wrong.

Defining your own exception classes

Classes are module 12's topic. For exceptions you need only one line of class syntax:

class InventoryError(Exception):
    pass

Read it as "define a new class named InventoryError that is a kind of Exception". The name in parentheses is the base class; the new class inherits everything from it (storing the message, printing, being catchable). pass is the empty body, since nothing needs to be added. By convention, exception class names end in Error.

You raise and catch your class like a built-in one:

class OutOfStock(Exception):
    pass

stock = {"apple": 3, "pear": 0}

def take(item):
    if stock.get(item, 0) == 0:
        raise OutOfStock(f"no {item} left")
    stock[item] -= 1

for item in ["apple", "pear"]:
    try:
        take(item)
        print("took", item)
    except OutOfStock as e:
        print("sorry:", e)
took apple
sorry: no pear left

Why bother instead of raising ValueError? Because a caller can now catch exactly this situation without accidentally catching an unrelated ValueError from somewhere deeper in the code.

A small hierarchy

Libraries usually define one base exception and specific subclasses:

class ShopError(Exception):
    """Base class for all errors raised by the shop module."""

class OutOfStock(ShopError):
    pass

class PaymentDeclined(ShopError):
    pass

(A docstring can serve as the body instead of pass.) Callers choose their precision: except OutOfStock: for one case, except ShopError: for "anything the shop module complains about", while bugs such as TypeError still propagate and get noticed.

Exception
 └── ShopError
      ├── OutOfStock
      └── PaymentDeclined

You can also inherit from a more specific built-in, for example class BadDateError(ValueError), so that existing code catching ValueError still works.

Exception chaining: raise ... from

Often you catch a low-level error and raise a higher-level one that makes sense to your caller. Do it with raise NewError(...) from original:

def load_port(text):
    try:
        return int(text)
    except ValueError as err:
        raise ConfigError(f"port must be a number, got {text!r}") from err

The traceback then shows both, connected:

Traceback (most recent call last):
  File "/work/main.py", line 6, in load_port
    return int(text)
ValueError: invalid literal for int() with base 10: 'http'

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

Traceback (most recent call last):
  File "/work/main.py", line 11, in <module>
    load_port("http")
  File "/work/main.py", line 8, in load_port
    raise ConfigError(f"port must be a number, got {text!r}") from err
ConfigError: port must be a number, got 'http'

The new exception's __cause__ attribute holds the original. Nothing is lost: the reader sees the meaningful message and the technical root cause.

If you raise a new exception inside a handler without from, Python still records the original, in __context__, but prints a different connecting line: "During handling of the above exception, another exception occurred". That wording suggests a bug in the handler. So use from err when the translation is intentional. To deliberately hide the original (rarely wise), write raise NewError(...) from None.

What raise does underneath

raise asks the VM to start unwinding with the given object. The VM attaches a traceback object to the exception, recording the current frame and line, and adds one entry for every frame it passes through on the way up. That is how the printed traceback knows every call in the chain, and why it is available later as e.__traceback__. If a handler catches it, unwinding stops in that frame and execution continues in the handler.

Common mistakes

Raising a string.

raise "something went wrong"
TypeError: exceptions must derive from BaseException

Raise an exception object: raise RuntimeError("something went wrong").

Inheriting from BaseException. Your classes should derive from Exception (or a subclass). Deriving from BaseException means except Exception: handlers will not catch them, like Ctrl-C.

Returning error codes instead. return -1 or return None for failure pushes the checking onto every caller, and forgetting it produces wrong results far from the cause. Raise.

Vague messages. raise ValueError("invalid") forces the reader to add print statements to learn what was invalid. Include the bad value with !r.

Forgetting from. Re-raising a translated error without from err produces the misleading "During handling..." text.

Recap

Going further: read PEP 3134 ("Exception Chaining and Embedded Tracebacks"), and in the REPL raise a chained exception and inspect e.__cause__, e.__context__ and e.__traceback__.tb_lineno.

Exercises

Exercise 1 · quiz

Choosing the exception type

A function set_volume(level) accepts integers from 0 to 10. It is called as set_volume(42). Which built-in exception is the conventional one to raise?

Answer
  • ValueError

42 has the right type (an int) but an unacceptable value, which is exactly what ValueError means. TypeError would fit set_volume("loud"). IndexError is for sequence indexes, and RuntimeError is a vague catch-all best avoided when a precise type exists.

Exercise 2 · quiz

Catching through the hierarchy

Given

class AppError(Exception):
    pass

class ConfigError(AppError):
    pass

which of these handlers would catch raise ConfigError("missing key")? Select all that apply.

Answer
  • except ConfigError:

  • except AppError:

  • except Exception:

  • except BaseException:

ConfigError inherits from AppError, which inherits from Exception, which inherits from BaseException, so handlers for any of those catch it. It has nothing to do with ValueError. This is why libraries define one base exception: callers can catch everything the library raises with a single except AppError:.

Exercise 3 · write code

Validate and raise

Write parse_age(text) in solution.py. It converts the string to an int and returns it, but:

Use isinstance(text, str) for the type check.

File: solution.py

Starting point

def parse_age(text):
    return int(text)
Hint 1

raise TypeError("age must be a string") creates the exception object and raises it.

Hint 2

Inside except ValueError as err:, write raise ValueError(...) from err so __cause__ points at the original.

Hint 3

Check the range after the conversion succeeded.

Reference solution
def parse_age(text):
    if not isinstance(text, str):
        raise TypeError("age must be a string")
    try:
        n = int(text)
    except ValueError as err:
        raise ValueError(f"not a whole number: {text!r}") from err
    if n < 0 or n > 150:
        raise ValueError(f"age out of range: {n}")
    return n

Exercise 4 · write code

A custom exception with data

In solution.py, define:

  1. class BankError(Exception) as a base class for this module's errors (body: pass).
  2. class InsufficientFunds(BankError) (body: pass).
  3. A function withdraw(balance, amount) that returns the new balance, but raises:
    • ValueError("amount must be positive") if amount <= 0,
    • InsufficientFunds(f"need {amount - balance} more") if amount > balance.
  4. A function try_withdraw(balance, amount) that calls withdraw and returns a string: "ok: <new balance>" on success, or "refused: <message>" if any BankError was raised. A ValueError must not be caught by try_withdraw; let it propagate.
withdraw(100, 30)          # 70
try_withdraw(100, 30)      # 'ok: 70'
try_withdraw(100, 130)     # 'refused: need 30 more'
try_withdraw(100, -5)      # raises ValueError

File: solution.py

Starting point

class BankError(Exception):
    pass

def withdraw(balance, amount):
    return balance - amount

def try_withdraw(balance, amount):
    return f"ok: {withdraw(balance, amount)}"
Hint 1

class InsufficientFunds(BankError): pass makes a subclass; nothing else is needed in the body.

Hint 2

In try_withdraw, catch BankError (the base), not Exception, so ValueError passes through.

Reference solution
class BankError(Exception):
    pass

class InsufficientFunds(BankError):
    pass

def withdraw(balance, amount):
    if amount <= 0:
        raise ValueError("amount must be positive")
    if amount > balance:
        raise InsufficientFunds(f"need {amount - balance} more")
    return balance - amount

def try_withdraw(balance, amount):
    try:
        new = withdraw(balance, amount)
    except BankError as e:
        return f"refused: {e}"
    return f"ok: {new}"