Skip to content
elephantoo

Exceptions & custom exceptions

Lesson 18 of 38 16 min read

try/except/else/finally, raising, the exception hierarchy, custom exception classes, chaining and EAFP.


Things go wrong: files go missing, users type letters where numbers belong, networks time out. Python reports problems by raising exceptions. Good programs anticipate the failures they can handle, deal with them gracefully, and let the unexpected ones fail loudly. This lesson covers try/except/else/finally, raising exceptions, the exception hierarchy, and designing your own exception classes.

What an exception looks like#

Python
numbers = [10, 20, 30]
print(numbers[5])
Output
Traceback (most recent call last):
  File "demo.py", line 2, in <module>
    print(numbers[5])
          ~~~~~~~^^^
IndexError: list index out of range

An unhandled exception stops the program and prints a traceback. Read it bottom-up: the exception type (IndexError), its message, then the chain of calls that led there.

Common built-in exceptions you'll meet constantly:

ExceptionTypical cause
ValueErrorright type, bad value: int("abc")
TypeErrorwrong type: "a" + 1
KeyErrormissing dict key
IndexErrorlist index out of range
AttributeErrorNone.upper(), misspelled method
FileNotFoundErroropening a missing file
ZeroDivisionError1 / 0
NameErrorusing an undefined name

Catching exceptions with try/except#

Python
def parse_age(text):
    try:
        age = int(text)
    except ValueError:
        print(f"{text!r} is not a number")
        return None
    return age


print(parse_age("42"))
print(parse_age("forty-two"))
Output
42
'forty-two' is not a number
None

Python runs the try block. If a ValueError occurs, it jumps to the matching except block; otherwise the except block is skipped.

Several exception types

Python
def safe_divide(a, b):
    try:
        return a / b
    except ZeroDivisionError:
        return float("inf")
    except TypeError as e:            # `as e` gives you the exception object
        print("bad input:", e)
        return None


print(safe_divide(10, 4))
print(safe_divide(1, 0))
print(safe_divide("10", 2))
Output
2.5
inf
bad input: unsupported operand type(s) for /: 'str' and 'int'
None

You can also group types: except (KeyError, IndexError):.

else and finally#

The full form has four parts:

Python
def read_number(path):
    try:
        f = open(path, encoding="utf-8")
    except FileNotFoundError:
        print("no such file")
        return None
    else:
        # runs only if the try block succeeded
        with f:
            return int(f.read())
    finally:
        # ALWAYS runs: success, exception, or return
        print(f"finished with {path}")


with open("n.txt", "w", encoding="utf-8") as out:
    out.write("7")

print(read_number("n.txt"))
print(read_number("missing.txt"))
Output
finished with n.txt
7
no such file
finished with missing.txt
None
  • else keeps the try block minimal — only the line that might fail goes in try, so you don't accidentally catch errors from other code.
  • finally is for cleanup that must always happen (closing connections, releasing locks). In practice, with statements handle most cleanup for you.

Raising exceptions#

Use raise to signal that your function can't do its job:

Python
def withdraw(balance, amount):
    if amount <= 0:
        raise ValueError(f"amount must be positive, got {amount}")
    if amount > balance:
        raise ValueError("insufficient funds")
    return balance - amount


print(withdraw(100, 30))
try:
    withdraw(100, 500)
except ValueError as e:
    print("Error:", e)
Output
70
Error: insufficient funds

Raising is better than returning a special value like -1 or None: callers can't accidentally ignore it, and the message explains what happened.

Inside an except block, a bare raise re-raises the current exception — useful when you want to log something but still let the error propagate:

Python
try:
    int("x")
except ValueError:
    print("logging the failure...")
    raise
Output
logging the failure...
Traceback (most recent call last):
  ...
ValueError: invalid literal for int() with base 10: 'x'

The exception hierarchy#

Exceptions are classes arranged in a hierarchy, and except SomeClass also catches all of its subclasses:

Output
BaseException
 ├── KeyboardInterrupt, SystemExit   (don't catch these casually)
 └── Exception
      ├── ArithmeticError → ZeroDivisionError, OverflowError
      ├── LookupError     → KeyError, IndexError
      ├── OSError         → FileNotFoundError, PermissionError, TimeoutError
      ├── ValueError, TypeError, AttributeError, ...
Python
data = {"a": 1}
for action in (lambda: data["b"], lambda: [1][3]):
    try:
        action()
    except LookupError as e:          # catches KeyError AND IndexError
        print(type(e).__name__, "caught")
Output
KeyError caught
IndexError caught

Order your except clauses from most specific to most general, because the first match wins.

Don't swallow everything

Python
try:
    reslt = 10 * 2
    print(result)          # typo: NameError
except Exception:
    pass                   # bug silently hidden!

This prints nothing and hides a typo. A bare except: is even worse — it also catches KeyboardInterrupt, so Ctrl+C stops working. Catch only what you expect and can handle. The one legitimate place for a broad except Exception is at the very top of a program or request handler, where you log the error (with traceback) before continuing.

Custom exceptions#

For your own applications and libraries, define exception classes that describe your domain's failures. Callers can then catch exactly what they care about:

Python
class BankError(Exception):
    """Base class for all errors in this module."""


class InsufficientFundsError(BankError):
    def __init__(self, balance, amount):
        self.balance = balance
        self.amount = amount
        super().__init__(f"cannot withdraw ₹{amount}; balance is only ₹{balance}")


class AccountLockedError(BankError):
    pass


def withdraw(balance, amount, locked=False):
    if locked:
        raise AccountLockedError("account is locked")
    if amount > balance:
        raise InsufficientFundsError(balance, amount)
    return balance - amount


try:
    withdraw(500, 800)
except InsufficientFundsError as e:
    print(e)
    print("short by", e.amount - e.balance)

try:
    withdraw(500, 100, locked=True)
except BankError as e:                 # catches any error from this module
    print(type(e).__name__, "-", e)
Output
cannot withdraw ₹800; balance is only ₹500
short by 300
AccountLockedError - account is locked

Best practices:

  • Inherit from Exception (never directly from BaseException).
  • Create one base exception per module or package (BankError) and derive specific ones from it.
  • Name them with an Error suffix.
  • Store useful data as attributes, and pass a clear message to super().__init__.

Exception chaining#

When you catch a low-level error and raise a higher-level one, use raise ... from ... to keep the original cause in the traceback:

Python
class ConfigError(Exception):
    pass


def load_port(settings):
    try:
        return int(settings["port"])
    except (KeyError, ValueError) as e:
        raise ConfigError("invalid or missing 'port' setting") from e


try:
    load_port({"port": "eighty"})
except ConfigError as e:
    print(e, "| caused by:", repr(e.__cause__))
Output
invalid or missing 'port' setting | caused by: ValueError("invalid literal for int() with base 10: 'eighty'")

Exception groups (Python 3.11+)#

When several operations fail at once — common with concurrent tasks — Python can raise an ExceptionGroup, handled with except*:

Python
try:
    raise ExceptionGroup("validation failed", [ValueError("bad email"), TypeError("age must be int")])
except* ValueError as group:
    print("value errors:", [str(e) for e in group.exceptions])
except* TypeError as group:
    print("type errors:", [str(e) for e in group.exceptions])
Output
value errors: ['bad email']
type errors: ['age must be int']

You'll see these with asyncio.TaskGroup in the concurrency lesson.

EAFP vs LBYL#

Python culture favours EAFP — "Easier to Ask Forgiveness than Permission": just try the operation and handle the failure, rather than checking everything first (LBYL, "Look Before You Leap").

Python
config = {"debug": "true"}

# LBYL
if "timeout" in config:
    timeout = int(config["timeout"])
else:
    timeout = 30

# EAFP
try:
    timeout = int(config["timeout"])
except KeyError:
    timeout = 30

print(timeout)
Output
30

EAFP avoids race conditions (a file can disappear between checking and opening it) and often reads more directly. For simple dict lookups, config.get("timeout", 30) is shorter still.

Common mistakes#

  • Catching too broadly (except: / except Exception: pass).
  • Huge try blocks — wrap only the line that can fail; use else for the rest.
  • Using exceptions for normal control flow in hot loops when a simple if would do.
  • Losing the original error — use raise ... from e.
  • Raising strings: raise "error" is a TypeError; raise exception instances.

What's next#

Custom exceptions are classes — which brings us to one of Python's biggest topics: object-oriented programming, starting with classes and objects.

Check your understanding

Quick quiz

0/3 answered
  1. 1.When does the else block of a try statement run?

  2. 2.Why is a bare except: (or except Exception: that does nothing) dangerous?

  3. 3.How should a custom exception be defined?

Finished reading?

Mark this lesson complete to track your progress.