Python · lesson 19 of 34
Beginner
16- 1 Introduction to Python
- 2 Setting Up Python
- 3 Python Variables and Type System
- 4 Python Data Types
- 5 Python Operators
- 6 Python Strings
- 7 Control Flow in Python
- 8 Python Functions and Parameters
- 9 Python Data Structures
- 10 Python Collections
- 11 Python Modules and Packages
- 12 Object-Oriented Programming in Python
- 13 Encapsulation and Name Mangling in Python
- 14 Polymorphism and Duck Typing in Python
- 15 Abstraction with Abstract Base Classes in Python
- 21 Building a Python CLI Tool
Intermediate
12- 16 Multiple Inheritance and MRO in Python
- 17 Composition vs Inheritance in Python
- 18 Python Dataclasses
- 19 Error Handling in Python
- 20 File I/O, pathlib, and JSON in Python
- 22 Python Decorators and Closures
- 23 Python Generators
- 24 Python Context Managers
- 25 Python Concurrency
- 27 Testing Python Code
- 28 Python Security
- 34 Python Interview Prep
Advanced
6Also part of: Data Engineering
Error Handling in Python
Handle exceptions with try/except/else/finally, chain and re-raise them, define custom exceptions, use except* and logging, all with real tracebacks.
By SkillByExample · Updated
When something goes wrong, Python raises an exception object that travels up the call stack until a matching except catches it, or the program stops with a traceback. Output below is from Python 3.13.
The exception hierarchy
Exceptions are classes, so catching a parent catches all its children.
for exc in (ZeroDivisionError, KeyError, FileNotFoundError, KeyboardInterrupt):
print(f"{exc.__name__:18}", " -> ".join(c.__name__ for c in exc.__mro__[1:]))
$ python hierarchy.py
ZeroDivisionError ArithmeticError -> Exception -> BaseException -> object
KeyError LookupError -> Exception -> BaseException -> object
FileNotFoundError OSError -> Exception -> BaseException -> object
KeyboardInterrupt BaseException -> object
except LookupError catches both KeyError and IndexError; except OSError catches every file-system error. KeyboardInterrupt (Ctrl-C) and SystemExit sit outside Exception, so except Exception deliberately does not stop a user from interrupting your program.
An uncaught exception
The traceback reads bottom-up: the last line is the error, the frames above it are how you got there.
def average(values):
return sum(values) / len(values)
def report(scores):
return f"mean={average(scores):.1f}"
print(report([90, 80]))
print(report([]))
$ python uncaught.py
mean=85.0
Traceback (most recent call last):
File "/home/user/py-demo/error-handling/uncaught.py", line 8, in <module>
print(report([]))
~~~~~~^^^^
File "/home/user/py-demo/error-handling/uncaught.py", line 5, in report
return f"mean={average(scores):.1f}"
~~~~~~~^^^^^^^^
File "/home/user/py-demo/error-handling/uncaught.py", line 2, in average
return sum(values) / len(values)
~~~~~~~~~~~~^~~~~~~~~~~~~
ZeroDivisionError: division by zero
The first call printed; the second raised in average, two calls deep. With no except anywhere, the exception unwound every frame and ended the program — and the ^ markers point at the exact sub-expression that failed.
try / except / else / finally
Each clause has a distinct job; the order they run in is the lesson.
import json
def load(text: str) -> dict:
try:
data = json.loads(text)
except json.JSONDecodeError as e:
print(f" except: bad JSON at char {e.pos}")
return {}
else:
print(" else: parsed OK")
return data
finally:
print(" finally: always")
for text in ['{"debug": true}', '{debug: true}']:
print(text)
print(" result:", load(text))
$ python try_clauses.py
{"debug": true}
else: parsed OK
finally: always
result: {'debug': True}
{debug: true}
except: bad JSON at char 1
finally: always
result: {}
else ran only on success, except only on failure, and finally ran both times — after the return statement inside else or except had computed its value but before the function actually returned. Keeping the success path in else means a bug there won’t be mistaken for a JSON error.
Catching several types, and re-raising
A tuple catches several types in one clause; a bare raise re-raises the current exception unchanged.
def parse_ratio(text: str) -> float:
try:
a, b = text.split("/")
return int(a) / int(b)
except (ValueError, ZeroDivisionError) as e:
print(f"bad ratio {text!r}: {type(e).__name__}: {e}")
return float("nan")
for t in ["3/4", "3/0", "three/4", "1/2/3"]:
print(parse_ratio(t))
def strict(text: str) -> float:
try:
return int(text)
except ValueError:
print("logging, then re-raising")
raise
try:
strict("x")
except ValueError as e:
print("caller got:", e)
$ python multiple.py
0.75
bad ratio '3/0': ZeroDivisionError: division by zero
nan
bad ratio 'three/4': ValueError: invalid literal for int() with base 10: 'three'
nan
bad ratio '1/2/3': ValueError: too many values to unpack (expected 2)
nan
logging, then re-raising
caller got: invalid literal for int() with base 10: 'x'
"1/2/3" failed in a place you might not expect — tuple unpacking, not int() — but it raises ValueError too, so the same clause handled it. strict logged and re-raised, so the caller still received the original exception with its traceback intact.
Custom exceptions and chaining
A small hierarchy of your own exceptions lets callers catch exactly the failures they can handle.
class AppError(Exception):
"""Base class for this application's errors."""
class NotFoundError(AppError):
def __init__(self, resource: str, id_: int):
super().__init__(f"{resource} {id_} not found")
self.resource, self.id = resource, id_
USERS = {1: "alice"}
def get_user(user_id: int) -> str:
try:
return USERS[user_id]
except KeyError as e:
raise NotFoundError("user", user_id) from e
try:
get_user(7)
except AppError as e:
print(type(e).__name__, "|", e, "|", e.resource, e.id)
print("cause:", repr(e.__cause__))
get_user(8)
$ python custom.py
NotFoundError | user 7 not found | user 7
cause: KeyError(7)
Traceback (most recent call last):
File "/home/user/py-demo/error-handling/custom.py", line 13, in get_user
return USERS[user_id]
~~~~~^^^^^^^^^
KeyError: 8
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "/home/user/py-demo/error-handling/custom.py", line 23, in <module>
get_user(8)
~~~~~~~~^^^
File "/home/user/py-demo/error-handling/custom.py", line 15, in get_user
raise NotFoundError("user", user_id) from e
NotFoundError: user 8 not found
except AppError caught the subclass, and the extra attributes travelled with it. from e stored the KeyError as __cause__, so the uncaught case prints both tracebacks — the low-level KeyError and the meaningful NotFoundError — joined by “direct cause”.
Exception groups and except* (3.11+)
When several things fail at once — typically concurrent tasks — Python raises an ExceptionGroup, and except* handles each type inside it.
def run_all(jobs):
errors = []
for job in jobs:
try:
job()
except Exception as e:
errors.append(e)
if errors:
raise ExceptionGroup("some jobs failed", errors)
def bad_value(): raise ValueError("bad config")
def timeout(): raise TimeoutError("db timed out")
def ok(): pass
try:
run_all([bad_value, ok, timeout, timeout])
except* ValueError as eg:
print("value errors:", [str(e) for e in eg.exceptions])
except* TimeoutError as eg:
print(f"{len(eg.exceptions)} timeouts")
$ python groups.py
value errors: ['bad config']
2 timeouts
Unlike ordinary except, both except* clauses ran: each one takes the matching exceptions out of the group. asyncio.TaskGroup raises exactly this kind of group (see async).
Logging instead of print
logging adds levels, a logger name and, via logger.exception, the full traceback.
import logging
import sys
logging.basicConfig(stream=sys.stdout, level=logging.INFO,
format="%(levelname)s %(name)s: %(message)s")
logger = logging.getLogger("orders")
def process_order(order_id: int) -> None:
logger.info("processing order %d", order_id)
logger.debug("not shown at INFO level")
try:
{}["items"]
except KeyError:
logger.exception("order %d failed", order_id)
process_order(42)
$ python logs.py
INFO orders: processing order 42
ERROR orders: order 42 failed
Traceback (most recent call last):
File "/home/user/py-demo/error-handling/logs.py", line 12, in process_order
{}["items"]
~~^^^^^^^^^
KeyError: 'items'
The debug line was filtered out by level=logging.INFO. logger.exception logs at ERROR and appends the traceback of the exception being handled, and the program carried on afterwards. Pass values as arguments (%d, order_id) rather than f-strings so formatting is skipped for filtered-out messages. (Real apps usually add %(asctime)s to the format; it’s left out here so the output is reproducible.)
The silent-swallow bug
except Exception: pass hides bugs, not just the errors you expected.
def send_email(user: dict) -> None:
print("sending to", user["emial"]) # typo
def notify_all(users):
for u in users:
try:
send_email(u)
except Exception:
pass
def notify_all_better(users):
for u in users:
try:
send_email(u)
except ConnectionError as e:
print("will retry:", e)
notify_all([{"email": "a@example.com"}])
print("notify_all finished; nobody was emailed")
notify_all_better([{"email": "a@example.com"}])
$ python swallow.py
notify_all finished; nobody was emailed
Traceback (most recent call last):
File "/home/user/py-demo/error-handling/swallow.py", line 20, in <module>
notify_all_better([{"email": "a@example.com"}])
~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/user/py-demo/error-handling/swallow.py", line 14, in notify_all_better
send_email(u)
~~~~~~~~~~^^^
File "/home/user/py-demo/error-handling/swallow.py", line 2, in send_email
print("sending to", user["emial"]) # typo
~~~~^^^^^^^^^
KeyError: 'emial'
The first version “succeeded” while doing nothing — the typo’s KeyError was swallowed. Catching only the error you can handle (ConnectionError) let the bug surface immediately, pointing at the typo.
Practice
1. Write safe_int(text, default=0) that returns default for anything int() can’t parse — including None — and test it on "42", "x" and None.
Solution
def safe_int(text, default=0):
try:
return int(text)
except (ValueError, TypeError):
return default
print(safe_int("42"), safe_int("x"), safe_int(None), safe_int("x", default=-1))
$ python safe_int.py
42 0 0 -1
int("x") raises ValueError but int(None) raises TypeError, so both must be listed.
2. Add a ValidationError(AppError) with a field attribute, raise it from create_user when age is negative, and catch it by the base class.
Solution
class AppError(Exception):
pass
class ValidationError(AppError):
def __init__(self, field: str, message: str):
super().__init__(f"{field}: {message}")
self.field = field
def create_user(name: str, age: int) -> dict:
if age < 0:
raise ValidationError("age", "must not be negative")
return {"name": name, "age": age}
for age in (30, -1):
try:
print(create_user("ana", age))
except AppError as e:
print("rejected:", e, "| field =", e.field)
$ python validation.py
{'name': 'ana', 'age': 30}
rejected: age: must not be negative | field = age
3. Show that finally runs even when the try block returns, by returning from inside try and printing in finally.
Solution
def f():
try:
print("try")
return "from try"
finally:
print("finally")
print(f())
$ python finally_return.py
try
finally
from try
The return value is computed first, then finally runs, then the function actually returns.
4. Change custom.py’s raise ... from e to from None and run get_user(8) uncaught. What disappears from the traceback?
Solution
class NotFoundError(Exception):
pass
USERS = {1: "alice"}
def get_user(user_id: int) -> str:
try:
return USERS[user_id]
except KeyError:
raise NotFoundError(f"user {user_id} not found") from None
get_user(8)
$ python from_none.py
Traceback (most recent call last):
File "/home/user/py-demo/error-handling/from_none.py", line 12, in <module>
get_user(8)
~~~~~~~~^^^
File "/home/user/py-demo/error-handling/from_none.py", line 10, in get_user
raise NotFoundError(f"user {user_id} not found") from None
NotFoundError: user 8 not found
The KeyError section is gone: from None suppresses the context, which is right when the low-level error is an implementation detail.