Skip to content
aviral gupta

// I1.1 · ~30 min · Intermediate

Raising and defining your own exceptions

After this lesson you can give your program its own exception types, group them under one base class, and catch exactly the errors you mean to.

Lesson 1 of 5 in I1 Errors in depth

Start of the module

You will be able to

  • Define your own exception class based on Exception, and raise it with a clear message
  • Group exceptions under a base class and catch the whole family with one except clause
  • Order except clauses from specific to general, and catch no more than you mean to
  1. Warm-up · Activity 1 of 7

    Warm-up from module B5: which of these lines raise an exception? Pick all that apply.

    Select all that apply.

  2. Predict · Activity 2 of 7

    Predict before you read on: OutOfStockError is raised, but the except clause names ShopError. What does this print?

    class ShopError(Exception):
        pass
    
    
    class OutOfStockError(ShopError):
        pass
    
    
    try:
        raise OutOfStockError("no pears left")
    except ShopError as err:
        print("shop problem:", err)
  3. Practice · Activity 3 of 7

    Fill in the base class, so that TemperatureError is an ordinary exception that except Exception catches.

    class TemperatureError(____):
        pass
    class TemperatureError():
  4. Practice · Activity 4 of 7

    ConfigError is derived from AppError. What does this print?

    class AppError(Exception):
        pass
    
    
    class ConfigError(AppError):
        pass
    
    
    try:
        raise ConfigError("missing key: port")
    except AppError:
        print("app error")
    except ConfigError:
        print("config error")
  5. Practice · Activity 5 of 7

    Python’s own exceptions form a hierarchy too. Match each exception to its direct base class.

  6. Brain teaser · Activity 6 of 7

    Brain teaser. Two exceptions go through the same try statement. What does this print?

    class AppError(Exception):
        pass
    
    
    class ConfigError(AppError):
        pass
    
    
    log = []
    for exc in [ConfigError("bad port"), AppError("disk full")]:
        try:
            raise exc
        except ConfigError:
            log.append("config")
        except AppError as err:
            log.append(f"app: {err}")
    print(log)
  7. Apply · Activity 7 of 7

    Mini-task. A thermostat accepts settings from 5 to 30 degrees. Define TemperatureError and two subclasses, TooColdError and TooHotError. Write check_setting(celsius), which raises the right one with a message. Then try 4, 21 and 35 in a loop with a single except clause, printing the class name and the message of each rejection.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

A bank with its own errors

A small bank module defines BankError as the base of its errors, and two specific ones. withdraw raises InsufficientFundsError or AccountLockedError, and a missing account raises the built-in KeyError. The loop gives the most specific error its own message, handles every other bank error with the base class, and keeps KeyError apart.

main.py

class BankError(Exception):
    """Base class for every error this module raises."""


class InsufficientFundsError(BankError):
    pass


class AccountLockedError(BankError):
    pass


LOCKED = {"bob"}


def withdraw(balances: dict[str, float], name: str, amount: float) -> float:
    if name in LOCKED:
        raise AccountLockedError(f"{name}'s account is locked")
    balance = balances[name]  # KeyError for an unknown name
    if amount > balance:
        raise InsufficientFundsError(f"{name} has {balance:.2f}, needs {amount:.2f}")
    balances[name] = balance - amount
    return balances[name]


balances = {"ada": 50.0, "bob": 20.0}
requests = [("ada", 20.0), ("ada", 40.0), ("bob", 5.0), ("eve", 1.0)]

for name, amount in requests:
    try:
        left = withdraw(balances, name, amount)
    except InsufficientFundsError as err:
        print("declined:", err)
    except BankError as err:
        print(f"bank error ({type(err).__name__}):", err)
    except KeyError:
        print("unknown account:", name)
    else:
        print(f"{name} withdrew {amount:.2f}, {left:.2f} left")

Run it with

python main.py

Output

ada withdrew 20.00, 30.00 left
declined: ada has 30.00, needs 40.00
bank error (AccountLockedError): bob's account is locked
unknown account: eve
  • The docstring alone is a valid class body, like pass.
  • InsufficientFundsError has its own clause above BankError; the other way round it could never run.
  • except BankError catches AccountLockedError, and type(err).__name__ still names the real class.
  • KeyError is not a BankError, so it needs its own clause.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Exercises

Exercise 1 of 2

A validation error of your own

Define ValidationError, derived from Exception. Then write validate_age(text), which returns the age as an int. For text that is not a whole number, raise ValidationError with the message not a number: followed by the text in repr form, for example not a number: 'ten'. For an age below 0 or above 150, raise ValidationError("age out of range: 200"), with the real value.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    int(text) raises ValueError for text such as "ten". Catch it with try and except ValueError.

  2. Hint 2

    Inside that except clause, raise your own error: raise ValidationError(f"not a number: {text!r}").

  3. Hint 3

    After the try statement, check the range with if not 0 <= age <= 150: and raise ValidationError(f"age out of range: {age}").

Show a solution

One way to solve it. Yours can look different and still pass the checks.

class ValidationError(Exception):
    pass


def validate_age(text: str) -> int:
    try:
        age = int(text)
    except ValueError:
        raise ValidationError(f"not a number: {text!r}")
    if not 0 <= age <= 150:
        raise ValidationError(f"age out of range: {age}")
    return age
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

class ValidationError(Exception):
    pass


def validate_age(text: str) -> int:
    return int(text)

test_main.py

from main import ValidationError, validate_age


def test_is_exception():
    """ValidationError is derived from Exception"""
    assert issubclass(ValidationError, Exception), "ValidationError must be derived from Exception"


def test_valid():
    """A valid age comes back as an int"""
    got = validate_age("42")
    assert got == 42, f"validate_age('42') returned {got!r}, expected 42"


def test_not_a_number():
    """Text that is not a number raises ValidationError"""
    try:
        validate_age("ten")
    except ValidationError as err:
        assert str(err) == "not a number: 'ten'", f"the message was {str(err)!r}"
    except ValueError:
        assert False, "validate_age raised ValueError; raise ValidationError instead"
    else:
        assert False, "validate_age('ten') returned normally"


def test_out_of_range():
    """200 and -1 are out of range"""
    for text in ["200", "-1"]:
        try:
            validate_age(text)
        except ValidationError as err:
            want = f"age out of range: {int(text)}"
            assert str(err) == want, f"for {text!r} the message was {str(err)!r}, expected {want!r}"
        else:
            assert False, f"validate_age({text!r}) returned normally"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Exercise 2 of 2

A family of config errors

read_port already raises MissingKeyError and BadValueError. Make both subclasses of ConfigError. Then fix describe(settings): it returns "port " plus the port, or "config problem: " plus the message for any ConfigError, using a single except clause. Other errors must not be caught.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    The base class goes in the parentheses: class MissingKeyError(ConfigError):. Do the same for BadValueError.

  2. Hint 2

    In describe, one clause for the whole family: except ConfigError as err:.

  3. Hint 3

    Do not use except Exception: the last test passes a number instead of a string, and that AttributeError must go through.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

class ConfigError(Exception):
    pass


class MissingKeyError(ConfigError):
    pass


class BadValueError(ConfigError):
    pass


def read_port(settings: dict[str, str]) -> int:
    if "port" not in settings:
        raise MissingKeyError("missing key: port")
    text = settings["port"]
    if not text.isdigit() or not 1 <= int(text) <= 65535:
        raise BadValueError(f"port must be 1-65535, got {text!r}")
    return int(text)


def describe(settings: dict[str, str]) -> str:
    try:
        port = read_port(settings)
    except ConfigError as err:
        return f"config problem: {err}"
    return f"port {port}"
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

class ConfigError(Exception):
    pass


class MissingKeyError(Exception):
    pass


class BadValueError(Exception):
    pass


def read_port(settings: dict[str, str]) -> int:
    if "port" not in settings:
        raise MissingKeyError("missing key: port")
    text = settings["port"]
    if not text.isdigit() or not 1 <= int(text) <= 65535:
        raise BadValueError(f"port must be 1-65535, got {text!r}")
    return int(text)


def describe(settings: dict[str, str]) -> str:
    try:
        port = read_port(settings)
    except MissingKeyError as err:
        return f"config problem: {err}"
    return f"port {port}"

test_main.py

from main import BadValueError, ConfigError, MissingKeyError, describe


def test_hierarchy():
    """Both errors are subclasses of ConfigError"""
    assert issubclass(MissingKeyError, ConfigError), "MissingKeyError must be derived from ConfigError"
    assert issubclass(BadValueError, ConfigError), "BadValueError must be derived from ConfigError"


def test_port():
    """A good port is described"""
    got = describe({"port": "8080"})
    assert got == "port 8080", f"describe returned {got!r}"


def test_missing():
    """A missing port is a config problem"""
    got = describe({})
    assert got == "config problem: missing key: port", f"describe returned {got!r}"


def test_bad_value():
    """A bad port is a config problem too"""
    got = describe({"port": "99999"})
    assert got == "config problem: port must be 1-65535, got '99999'", f"describe returned {got!r}"


def test_other_errors_pass():
    """Errors that are not ConfigErrors are not caught"""
    try:
        describe({"port": 8080})  # type: ignore[dict-item]
    except AttributeError:
        pass
    else:
        assert False, "describe caught an AttributeError; catch only ConfigError"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Common mistakes

An exception class without a base class

class StockError:
    pass


raise StockError("no pears left")

What Python prints

TypeError: StockError() takes no arguments

Why, and the fix

Without a base class, StockError is an ordinary class, not an exception. It does not even accept a message, and raise would reject it too. Name the base class: class StockError(Exception):. Then it takes a message, and raise and except work with it like with any built-in exception.

Expecting a subclass clause to catch the base class

class AppError(Exception):
    pass


class ConfigError(AppError):
    pass


try:
    raise AppError("disk full")
except ConfigError:
    print("config problem")

What Python prints

AppError: disk full

Why, and the fix

except ConfigError catches ConfigError and classes derived from it. AppError is its base class, not a subclass, so the error goes through uncaught. To handle every error of the family, catch the base class: except AppError:.

Using a base class before it is defined

class ConfigError(AppError):
    pass


class AppError(Exception):
    pass

What Python prints

NameError: name 'AppError' is not defined

Why, and the fix

A class statement runs from top to bottom like any other line, and the base class in the parentheses must already exist. Define the base class first, then the classes derived from it.

Python in the browser: Pyodide 314.0.7, MPL-2.0. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

Your own exception class

class PaymentError(Exception): pass creates a new exception type. The part in parentheses is its base class: PaymentError is a kind of Exception, so everything you know still works. raise PaymentError("card declined") raises it, except PaymentError as err: catches it, and str(err) is the message. The body can be pass or a docstring. Classes in general come in module I2; for an exception class, the class line and pass are all you need. Derive from Exception, not BaseException, which also covers exits such as KeyboardInterrupt. By convention the name ends in Error. Your own type tells the caller exactly what went wrong, instead of one more ValueError.

Hierarchies: catch a family with its base class

An except clause matches the class it names and every class derived from it, but not the other way round. With class ShopError(Exception) and class PaymentError(ShopError), except ShopError catches a PaymentError too, while except PaymentError does not catch a plain ShopError. So give a module one base class and derive its specific errors from it: callers can catch everything from your module with one clause, or single out one case. Python does the same: KeyError and IndexError derive from LookupError, ZeroDivisionError from ArithmeticError.

Specific first, and no wider than needed

Python checks except clauses from the top and runs the first one that matches. A base class listed first catches all its subclasses, so a clause for a subclass below it can never run. Put the most specific classes first and the base class last. And catch only what you expect: except Exception also swallows your own typos and bugs, such as a TypeError from bad data, and hides them behind a friendly message. raise PaymentError without parentheses is shorthand for raise PaymentError(): an exception with no message.

Sources

Last reviewed September 29, 2026