Execute Program

Python in Detail: Custom Exceptions

Welcome to the Custom Exceptions lesson!

This lesson is shown as static text below. However, it's designed to be used interactively. Click the button below to start!

  • Let's say we're transferring funds from one bank account to another. If the account's balance is too low, we raise a ValueError. ValueError is a good match: it means that the argument had the correct data type, but the specific value is not acceptable.

  • >
    account_balances = {
    "Amir": 300,
    "Betty": 500,
    }

    def transfer_funds(amount, from_account, to_account):
    if account_balances[from_account] < amount:
    raise ValueError("insufficient funds")

    account_balances[from_account] -= amount
    account_balances[to_account] += amount
  • Note: this code example reuses elements (variables, etc.) defined in earlier examples.
    >
    transfer_funds(100, "Amir", "Betty")
    (account_balances["Amir"], account_balances["Betty"])
    Result:
    (200, 600)Pass Icon
  • Note: this code example reuses elements (variables, etc.) defined in earlier examples.
    >
    transfer_funds(350, "Amir", "Betty")
    (account_balances["Amir"], account_balances["Betty"])
    Result:
    ValueError: insufficient fundsPass Icon
  • If we pass a non-existent account to transfer_funds, we get a KeyError when we look up account_balances[...]. This happens due to standard dict behavior.

  • Note: this code example reuses elements (variables, etc.) defined in earlier examples.
    >
    transfer_funds(100, "Amiir", "Betty")
    (account_balances["Amir"], account_balances["Betty"])
    Result:
    KeyError: 'Amiir'Pass Icon
  • This error message is less clear, but we can improve it. We'll customize the message to tell us what's really wrong: no account with that name exists. We'll also change the exception class to ValueError, since we're now describing the problem in terms of the application (that account doesn't exist) rather than a Python data structure (the dict doesn't have that key).

  • >
    account_balances = {
    "Amir": 300,
    "Betty": 500,
    }

    def transfer_funds(amount, from_account, to_account):
    if from_account not in account_balances:
    raise ValueError(f"Account doesn't exist: {from_account}")
    if to_account not in account_balances:
    raise ValueError(f"Account doesn't exist: {to_account}")
    if account_balances[from_account] < amount:
    raise ValueError("insufficient funds")

    account_balances[from_account] -= amount
    account_balances[to_account] += amount

    transfer_funds(100, "Amiir", "Betty")
    [account_balances["Amir"], account_balances["Betty"]]
    Result:
    ValueError: Account doesn't exist: AmiirPass Icon
  • The code detects the mistake either way. But when debugging, ValueError("Account doesn't exist: Amiir") is more helpful than a generic KeyError.

  • This matters even more when we consider that non-programmers sometimes see our error messages. To a bank teller, Account doesn't exist: Amiir will make a lot more sense than KeyError: 'Amiir'!

  • If we call transfer_funds inside of a try:, and we end up catching a ValueError, does that mean that the account didn't exist? Not necessarily! A lot of built-in Python operations raise built-in exceptions like KeyError and ValueError.

  • >
    from math import sqrt

    sqrt(-1)
    Result:
  • >
    int("Amir")
    Result:
  • When we plan to catch an exception, it's usually a good idea to raise an instance of a custom exception class, rather than one of Python's built-in exception classes. For example, if we catch a NoAccountError exception, we can be sure that it wasn't caused by a math operation like the ones above.

  • We can define a custom exception class by inheriting from any exception class. In this case, we'll inherit from Exception itself, which is a good default choice. The next example raises a NoAccountError when an account doesn't exist. We'll catch it in a moment.

  • >
    class NoAccountError(Exception):
    pass

    account_balances = {
    "Amir": 300,
    "Betty": 500,
    }

    def transfer_funds(amount, from_account, to_account):
    if from_account not in account_balances:
    raise NoAccountError(f"Can't transfer from {from_account}")
    if to_account not in account_balances:
    raise NoAccountError(f"Can't transfer to {to_account}")
    if account_balances[from_account] < amount:
    raise ValueError("insufficient funds")

    account_balances[from_account] -= amount
    account_balances[to_account] += amount
  • Note the new error type below!

  • Note: this code example reuses elements (variables, etc.) defined in earlier examples.
    >
    transfer_funds(100, "Amiir", "Betty")
    (account_balances["Amir"], account_balances["Betty"])
    Result:
    NoAccountError: Can't transfer from AmiirPass Icon
  • Now any code that calls transfer_funds can catch NoAccountErrors. When that happens, we can be sure that the account doesn't exist.

  • Note: this code example reuses elements (variables, etc.) defined in earlier examples.
    >
    amount = 100
    transferred_amount = None
    try:
    transfer_funds(amount, "Amiir", "Betty")
    transferred_amount = amount
    except NoAccountError:
    transferred_amount = 0
    transferred_amount
    Result:
    0Pass Icon
  • Here's a code problem:

    The bank limits daily account transfers to $1,000. Trying to transfer over the limit currently raises a generic ValueError. Define a new exception class, TransferLimitError. Then raise the exception in transfer_funds instead of a ValueError.

    A hint: your custom exception doesn't need any additional functionality, so its body can simply be pass.

    account_balances = {
    "Amir": 2150,
    "Betty": 300,
    "Cindy": 4200,
    }
    class TransferLimitError(Exception):
    pass

    def transfer_funds(amount, from_account, to_account):
    if from_account not in account_balances:
    raise KeyError(f"Can't transfer from {from_account}")
    if to_account not in account_balances:
    raise KeyError(f"Can't transfer to {to_account}")
    if account_balances[from_account] < amount:
    raise ValueError("Insufficient funds")
    if amount > 1000:
    raise TransferLimitError("Transfer limit exceeded.")

    account_balances[from_account] -= amount
    account_balances[to_account] += amount
    over_limit_transfer = lambda: transfer_funds(1200, "Amir", "Betty")
    assert_raises(TransferLimitError, over_limit_transfer)

    assert_raises(KeyError, lambda: transfer_funds(120, "Bettty", "Cindy"))
    assert_raises(KeyError, lambda: transfer_funds(550, "Betty", "Amiir"))

    transfer_funds(500, "Cindy", "Betty")

    assert account_balances["Cindy"] == 3700
    assert account_balances["Betty"] == 800
    assert account_balances["Amir"] == 2150
    Goal:
    None
    Yours:
    NonePass Icon