Exceptions: Patterns Beyond the Basics
1 · The lesson
readYou already know try/except, how to catch specific built-ins, and not to write bare except:. This lesson shows how error handling looks in production Python code: custom exception types, chained tracebacks, exception groups, ways to ignore an expected error, and the EAFP style that makes idiomatic Python different from defensive Java code.
By the end, you will see why "catch and ignore" is almost always a mistake.
1. Custom Exception Classes: Two Lines of Code
A custom exception takes two lines. It lets you use the most important pattern in error handling: catch by type, not by string.
class ConfigError(Exception): """Raised when application configuration is invalid or missing.""" # Raise it def load_port(env_value): try: return int(env_value) except ValueError: raise ConfigError(f"PORT must be an integer, got {env_value!r}") # Catch it, naming the exact exception type try: port = load_port("not-a-number") except ConfigError as e: print(f"Config problem: {e}")
The alternative is to catch ValueError and read str(e) to find out what happened. That breaks easily, because error messages can change, but types don't. Define your own exception types whenever the meaning of an error in your code differs from any built-in.
2. Exception Hierarchies
A small inheritance tree lets callers catch broadly or narrowly:
class AppError(Exception): """Base class for everything this app raises.""" class ValidationError(AppError): """The user gave us bad input.""" class AuthError(AppError): """Authentication or authorisation failed.""" class NetworkError(AppError): """A downstream service call failed."""
Now callers can choose to catch broad or specific errors:
try: do_work() except ValidationError: # show form errors ... except AuthError: # redirect to login ... except AppError: # generic fallback for anything else our code raises ...
setup added so this can run · defines ValidationError, AuthError, AppError, do_work
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) ValidationError = _AutoMock('ValidationError') AuthError = _AutoMock('AuthError') AppError = _AutoMock('AppError') def do_work(*_a, **_kw): print('-> do_work() called') return _AutoMock('do_work()')
The top level of your web handler can catch AppError and return a 500 error. Code deeper inside catches the specific subclass. The user of your library gets one stable type to catch, AppError, and you don't have to list every possible internal failure.
Convention: every library or app has one base exception class. Every internal exception inherits from it. Calling code never has to catch a built-in like KeyError from inside your code: you catch it, and raise your own exception type instead, at the edge of your code.
3. The Full try/except/else/finally Flow
Here are all four parts in one example, with the order of execution marked:
def parse_int(text): try: n = int(text) # 1. risky code except ValueError as e: print(f"bad: {e}") # 2. only if int() raised ValueError return None else: print("parsed cleanly") # 3. only if NO exception (and no early return) return n finally: print("done parsing") # 4. ALWAYS runs: after success, failure, or an early return parse_int("42") # parsed cleanly # done parsing # → 42 parse_int("xyz") # bad: invalid literal for int() with base 10: 'xyz' # done parsing # → None
else is for code that should run after the try succeeded, and that doesn't need exception handling itself. This keeps your try block small: only the line that can fail goes in it.
finally is for cleanup. It runs on success, on an exception, and when the function returns from inside the try or except. A with block works like a try/finally, which is why with replaces most hand-written finally blocks today.
4. Re-Raising and Chaining
Sometimes you want to catch, log, and let the original error continue:
try: risky() except Exception: log("something failed in risky()") raise # bare `raise` inside except re-raises the current exception
setup added so this can run · defines risky, log
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def risky(*_a, **_kw): print('-> risky() called') return _AutoMock('risky()') def log(*_a, **_kw): print('-> log() called') return _AutoMock('log()')
Sometimes you want to wrap a low-level error in your own application-level error, and keep the original cause for debugging:
class DatabaseError(AppError): pass def get_user(user_id): try: return db.query("SELECT ...", user_id) except sqlite3.OperationalError as e: raise DatabaseError(f"failed to fetch user {user_id}") from e
setup added so this can run · defines AppError, sqlite3, db
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) AppError = _AutoMock('AppError') sqlite3 = _AutoMock('sqlite3') db = _AutoMock('db')
raise NewError(...) from e sets the new exception's __cause__ to e. The traceback shows both:
sqlite3.OperationalError: database is locked The above exception was the direct cause of the following exception: myapp.DatabaseError: failed to fetch user 42
One traceback gives you two pieces of information: what went wrong in your application (your DatabaseError) and why (the underlying sqlite error).
When you really want to hide the chain, use from None. You usually do this when the inner error shows internal details that the users of your API don't need:
try: raw = json.loads(payload) except json.JSONDecodeError: raise ValidationError("Body must be valid JSON") from None
setup added so this can run · defines payload, json, ValidationError
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) payload = _AutoMock('payload') json = _AutoMock('json') def ValidationError(*_a, **_kw): print('-> ValidationError() called') return _AutoMock('ValidationError()')
__cause__ becomes None, the traceback doesn't show the original exception, and the caller sees only your message. Use this rarely. Usually from e is the right choice, because hiding the cause throws away debugging information.
5. contextlib.suppress: Ignoring an Expected Error
Sometimes the right reaction to an exception is nothing. The verbose form:
try: os.remove("scratch.tmp") except FileNotFoundError: pass
setup added so this can run · defines os
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) os = _AutoMock('os')
…takes four lines for a simple idea. contextlib.suppress does the same thing in two:
from contextlib import suppress with suppress(FileNotFoundError): os.remove("scratch.tmp")
setup added so this can run · defines os
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) os = _AutoMock('os')
Use it when:
- The exception is expected and there's nothing to do about it.
- The intent ("ignore exactly this error type") is clear from the code.
Do not use it as with suppress(Exception): to silence anything that goes wrong. That's the same bad pattern as try/except Exception: pass, only shorter.
6. contextlib.contextmanager: Build Your Own with
Anywhere you have a try/finally pair that you'd want to reuse, @contextmanager turns it into a with-block.
import time from contextlib import contextmanager @contextmanager def timer(label): start = time.perf_counter() try: yield # execution jumps back to the `with` body here finally: elapsed = time.perf_counter() - start print(f"{label}: {elapsed:.3f}s") with timer("expensive op"): sum(i * i for i in range(1_000_000)) # expensive op: 0.041s
Everything before yield runs on entry. Everything after runs on exit, in a finally so it runs even if the with body raises. With one decorator and one yield, you have a reusable way to set up and clean up a resource.
Full coverage of context managers (including the class-based __enter__/__exit__ form) is in the Advanced path.
7. ExceptionGroup and except* (Python 3.11+)
Sometimes you have several errors to report at once: several concurrent tasks that each failed, or a validation pass that finds five problems instead of stopping at the first. ExceptionGroup bundles them:
def validate_all(records): errors = [] for i, r in enumerate(records): try: validate(r) except ValidationError as e: errors.append(e) if errors: raise ExceptionGroup("validation failed", errors)
setup added so this can run · defines ValidationError, validate
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) ValidationError = _AutoMock('ValidationError') def validate(*_a, **_kw): print('-> validate() called') return _AutoMock('validate()')
The caller uses except* (note the star) to handle each type that's anywhere in the group:
try: validate_all(records) except* ValidationError as eg: for e in eg.exceptions: print(" -", e) except* AuthError as eg: for e in eg.exceptions: print(" auth issue:", e)
setup added so this can run · defines ValidationError, AuthError, validate_all, records
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) ValidationError = _AutoMock('ValidationError') AuthError = _AutoMock('AuthError') def validate_all(*_a, **_kw): print('-> validate_all() called') return _AutoMock('validate_all()') records = _AutoMock('records')
except* matches and removes all the exceptions of that type from the group, even if they're nested inside it. Any exceptions that don't match stay in a group and propagate up. This is the right tool for asyncio.TaskGroup, where N tasks can fail in parallel and you want to report all of them.
Before Python 3.11, the workaround is to raise a single exception that stores the list of errors in an attribute. It is less clean, but it is the same idea.
8. __traceback__ and Logging Exceptions
Every exception object carries its traceback as e.__traceback__. You rarely use it directly. Instead, you let logging do the work:
import logging logger = logging.getLogger(__name__) try: risky() except Exception: logger.exception("risky() blew up") # also writes the full traceback to the log
setup added so this can run · defines risky
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def risky(*_a, **_kw): print('-> risky() called') return _AutoMock('risky()')
logger.exception(msg) is equivalent to logger.error(msg, exc_info=True). Use it inside except blocks. Use it instead of print(e). print gives you the message but not the traceback, and the traceback is the part that tells you where the error came from.
If you need the formatted string for a custom error reporter:
import traceback try: risky() except Exception as e: tb_str = "".join(traceback.format_exception(type(e), e, e.__traceback__)) send_to_sentry(tb_str)
setup added so this can run · defines risky, send_to_sentry
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def risky(*_a, **_kw): print('-> risky() called') return _AutoMock('risky()') def send_to_sentry(*_a, **_kw): print('-> send_to_sentry() called') return _AutoMock('send_to_sentry()')
9. EAFP: Easier to Ask Forgiveness than Permission
Pythonic error handling prefers try/except over if check: do. The reasons:
- No race condition: between a check and the action that follows it, something else can change the situation. The try/except does the action and reacts to what really happened.
- Faster on the happy path: in CPython, taking an exception is expensive, but not taking one is essentially free. If failures are rare, EAFP is also faster.
- More general: a check has to anticipate every reason the action could fail. The try has to anticipate only the exception types you care about.
# LBYL (Look Before You Leap), not the usual Python style if "name" in user and isinstance(user["name"], str): name = user["name"].strip() else: name = "anonymous" # EAFP (Easier to Ask Forgiveness than Permission), the usual Python style try: name = user["name"].strip() except (KeyError, AttributeError): name = "anonymous"
setup added so this can run · defines user
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) user = _AutoMock('user')
The EAFP version uses one expression for the normal case. It also handles the case where user["name"] is None (which isinstance(..., str) would catch but in a different style). Both styles are valid, but in Python, the second is the default.
There is one exception: when checking first is really cheaper or clearer. Checking if x is None before calling a method on x is fine. It is one comparison and reads naturally.
10. Common Mistakes
1. Bare except: (still)
It catches KeyboardInterrupt and SystemExit, so Ctrl+C can no longer stop your program. Don't use it.
2. except Exception: pass at the top of a function "just in case"
Now you have hidden every bug, every typo, every NameError. The function seems to "work", but it does nothing and shows no error. Catch only the specific types you can handle.
3. Silent pass in except blocks
If you must catch, at least log the error. An error that is hidden and never shown can cost you hours of debugging later:
# WRONG try: sync_to_remote() except Exception: pass # RIGHT try: sync_to_remote() except Exception: logger.exception("remote sync failed; continuing")
setup added so this can run · defines sync_to_remote, logger
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def sync_to_remote(*_a, **_kw): print('-> sync_to_remote() called') return _AutoMock('sync_to_remote()') logger = _AutoMock('logger')
4. Losing the original traceback
# Wrong: the original error is not set as the cause try: parse(payload) except ValueError: raise MyError("bad payload") # __cause__ is None, so debugging is harder # RIGHT try: parse(payload) except ValueError as e: raise MyError("bad payload") from e
setup added so this can run · defines parse, payload, MyError
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def parse(*_a, **_kw): print('-> parse() called') return _AutoMock('parse()') payload = _AutoMock('payload') def MyError(*_a, **_kw): print('-> MyError() called') return _AutoMock('MyError()')
5. Raising strings
raise "something went wrong" # TypeError (you can only raise an Exception subclass)
Old versions of Python 2 allowed it. Python 3 doesn't. Always write
raise SomeException("message").
6. Exceptions as control flow when an if would do
Examples: catching StopIteration to detect the end of a loop in normal code, or using try around dict access when .get() does the job. Exceptions cost almost nothing when they are not raised, but they are not the right tool for normal, expected branching.
🎯 Your Turn: Build a Validation System
Design a small validation framework:
1. A base ValidationError(Exception) for everything validation-related.
2. Subclasses RequiredFieldError (a field is missing) and TypeMismatchError (a field has the wrong type).
3. A function validate(data, schema) where schema is a dict of field_name → expected_type. The function should collect all errors, not stop at the first.
4. If any errors were collected, raise an ExceptionGroup (Python 3.11+) that bundles them.
schema = {"name": str, "age": int, "email": str}
data = {"name": "Ada", "age": "thirty"} # missing email, wrong type for age
# validate(data, schema) should raise an ExceptionGroup containing:
# - TypeMismatchError("age expected int, got str")
# - RequiredFieldError("email is required")Skeleton:
class ValidationError(Exception): pass class RequiredFieldError(ValidationError): pass class TypeMismatchError(ValidationError): pass def validate(data, schema): errors = [] # TODO 1: for each (field, expected_type) in schema: # - if the field is missing from data, add a RequiredFieldError # - elif data[field] is not an instance of expected_type, add a TypeMismatchError # TODO 2: if errors: raise ExceptionGroup("validation failed", errors) ...
Hint 1: Collect, don't stop
The point of the exercise is that one bad field shouldn't hide the rest. Build up alist of ValidationError instances as you iterate the schema. Raise once at the end.
Hint 2: isinstance for the type check
isinstance(value, expected_type) handles inheritance correctly. type(value) is expected_type would miss subclasses (for example, bool is a subclass of int, but for this exercise, accepting True as an int is fine).
Show full solution
class ValidationError(Exception): """Base class for all validation problems.""" class RequiredFieldError(ValidationError): def __init__(self, field): super().__init__(f"{field!r} is required") self.field = field class TypeMismatchError(ValidationError): def __init__(self, field, expected, got): super().__init__( f"{field!r} expected {expected.__name__}, got {type(got).__name__}" ) self.field = field self.expected = expected self.got = got def validate(data, schema): """Validate every field in `schema` against `data`. Raise an ExceptionGroup with all errors.""" errors = [] for field, expected_type in schema.items(): if field not in data: errors.append(RequiredFieldError(field)) elif not isinstance(data[field], expected_type): errors.append(TypeMismatchError(field, expected_type, data[field])) if errors: raise ExceptionGroup("validation failed", errors) # Demo schema = {"name": str, "age": int, "email": str} data = {"name": "Ada", "age": "thirty"} try: validate(data, schema) except* RequiredFieldError as eg: for e in eg.exceptions: print(" missing:", e) except* TypeMismatchError as eg: for e in eg.exceptions: print(" wrong type:", e) # missing: 'email' is required # wrong type: 'age' expected int, got str
What you built:
- A real hierarchy:
ValidationErrorlets a caller catch any validation error with oneexcept, and the subclasses let them be specific. - Exception attributes for each field (
e.field,e.expected,e.got), so callers can build structured responses (JSON API error payloads, highlighted form fields) without parsing the message string. - All errors at once: the caller gets every problem in one pass. With "stop on first error", the user has to fix → submit → fix → submit again and again.
except*dispatch: each error type gets its own handler, even though they all arrived in the same group.
In a real app, you'd extend this with field paths for nested data ("address.zip"), custom validators per field, and probably a third-party library (pydantic, attrs, cattrs) that does all this and much more. But the basic pattern is the one you just built.
What You Learned
- Custom exceptions take two lines and let you catch errors by type. Define a base class for your app/library, and subclass it for each meaningful failure mode.
try/except/else/finally:elseruns on success,finallyalways runs.- Chaining:
raise NewError(...) from originalpreserves the cause in__cause__.from Nonehides it (use this rarely). contextlib.suppress(ExceptionType)when you really want to ignore one specific error type.@contextmanagerturns atry/finallyinto awith-block in 6 lines.ExceptionGroup+except*(3.11+) for collecting and dispatching multiple parallel errors.logger.exception(msg)in except blocks logs the full traceback.- EAFP is Pythonic. Try the normal case, and catch the errors that can really happen.
- Never write bare
except:, neverpasssilently in an except, never lose the original traceback.
Next: Regular Expressions: pattern matching for text, when (and when not) to use it, and the few patterns you will use most often.
Practice this
on practicepython.inShort exercises that run in your browser and tell you what your code did, as well as whether a test passed.
Found this useful? Share it with a friend who’s learning Python.