Error handling is not about hiding problems — it is about directing them. When something fails (a missing file, a bad number, a broken network), Python raises an exception: a special object that travels up the call stack until someone catches it. If no one does, the program stops and prints a traceback. Your job as a programmer is to decide, for each kind of failure, whether the current function should handle it, pass it along, or add context.
Every Python exception is an instance of a class that inherits from BaseException. The vast majority you will write or catch inherit from Exception, which itself inherits from BaseException. This hierarchy matters: you can catch a whole family by catching a parent class. except OSError catches FileNotFoundError, PermissionError, and more, because they are all subclasses of OSError.
There are two distinct phases: raising and catching. Python itself raises ZeroDivisionError, KeyError, TypeError and so on when something goes wrong. Your code catches with try/except when it can do something useful: fall back to a default, try again, report a nicer message. Your code also raises (with raise ValueError("bad input")) when callers give it data it cannot handle.
Good error-handling code is almost boring: narrow except clauses that match only the failures you understand, informative messages that include the offending value, and a preference for letting unexpected exceptions crash the program (with a full traceback) instead of swallowing them. The golden rule is: never catch what you cannot handle.
The exception hierarchy
BaseException is the root. Two special siblings, SystemExit and KeyboardInterrupt, live directly under it so that except Exception does not swallow Ctrl-C or sys.exit(). Everything else you care about descends from Exception: ValueError, TypeError, KeyError, IndexError, LookupError, OSError and its many subclasses, and so on.
Browse the built-in exceptions once in the docs so you know the vocabulary. Using the right type when you raise makes your errors easy to catch precisely.
Reading a traceback
Python prints tracebacks bottom-up: the deepest frame is at the bottom, ending with the exception class and message. Each frame above shows the file, line and source that called the next frame. Read the last line first; the rest is context that explains how you got there.
A traceback is a report, not the bug itself. Two common patterns: the bug is on the exact line shown (a typo, a missing check), or the bug is a few frames up and the shown line is where the bad value finally turned into an exception. Practice tracing both.
The essential exception vocabulary for everyday code.
| Tool | Purpose |
|---|---|
Exceptionbase class | Root for normal recoverable errors. |
ValueErrorexception | A value has the right type but is not acceptable. |
TypeErrorexception | An operation was applied to an incompatible type. |
KeyErrorexception | A mapping key is missing. |
IndexErrorexception | A sequence index is out of range. |
OSErrorexception | OS-level error (FileNotFound, Permission, ...). |
ZeroDivisionErrorexception | Division or modulo by zero. |
tracebackmodule | Programmatic access to the call stack of an error. |
Introduction to Error Handling code example
The script below produces three different exceptions on purpose and shows how to catch, inspect and re-raise them.
# Lesson: Introduction to Error Handling
import traceback
def divide(a: float, b: float) -> float:
return a / b
def safe_divide(a: float, b: float) -> float | None:
try:
return divide(a, b)
except ZeroDivisionError:
return None
print("safe_divide(10, 2):", safe_divide(10, 2))
print("safe_divide(1, 0): ", safe_divide(1, 0))
# Trigger a TypeError and inspect it
try:
len(42)
except TypeError as err:
print("caught:", type(err).__name__, "->", err)
print("is Exception?", isinstance(err, Exception))
# Trigger a ValueError and re-raise with context
def parse_age(raw: str) -> int:
try:
return int(raw)
except ValueError as err:
raise ValueError(f"not an age: {raw!r}") from err
try:
parse_age("old")
except ValueError as err:
print("wrapped:", err, "|cause:", type(err.__cause__).__name__)
# traceback.print_exc() # uncomment to see the full chain
Four moments matter:
1) `except ZeroDivisionError` catches only the one failure we have a plan for.
2) `type(err).__name__` is handy when you want the class name without 'Error' twice.
3) `raise ... from err` preserves the original cause; tracebacks show both.
4) `isinstance(err, Exception)` is the guard used by the catch-all `except Exception`.
Two tiny exercises: narrow catch, and catch-then-default.
def as_int(s: str, default: int = 0) -> int:
try:
return int(s)
except ValueError:
return default
print(as_int("42")) # 42
print(as_int("oops")) # 0
def first_item(seq):
try:
return seq[0]
except IndexError:
return None
print(first_item([1, 2])) # 1
print(first_item([])) # None
Assertions that verify the hierarchy and shape of the API.
assert issubclass(FileNotFoundError, OSError)
assert issubclass(ValueError, Exception)
try:
int("x")
except ValueError:
pass
else:
raise AssertionError("should have raised")
Running prints:
safe_divide(10, 2): 5.0
safe_divide(1, 0): None
caught: TypeError -> object of type 'int' has no len()
is Exception? True
wrapped: not an age: 'old' |cause: ValueError