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 is what production Python error-handling actually looks like — custom exception types, chained tracebacks, exception groups, suppressed-error patterns, and the EAFP mindset that makes idiomatic Python feel different from defensive Java.
By the end, "catch and ignore" will offend you on sight.
1. Custom Exception Classes — The Two-Line Win
A custom exception is two lines and unlocks 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 — specifically try: port = load_port("not-a-number") except ConfigError as e: print(f"Config problem: {e}")
The alternative — catching ValueError and reading str(e) to figure out what happened — is brittle. Error messages change. 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 choose their granularity:
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 boundary call site at the top of your web handler can catch AppError and return a 500. Domain code catches the specific subclass. The user of your library gets one stable type to filter on — AppError — without you having to enumerate every internal failure mode.
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 your internals — you wrap and re-raise at the boundary.
3. The Full try/except/else/finally Flow
Worth seeing all four in one example, with the execution order spelled out:
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 — success, failure, or 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 "what to do after the try succeeded that doesn't itself need exception handling." Keeps your try block tight — only the line that can fail goes in it.
finally is the cleanup hammer — runs on success, exception, and when the function returns from inside the try or except. It's how with is implemented under the hood (which is why with replaces most hand-written finally blocks these days).
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 one, preserving the 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
Two pieces of information in one traceback: what went wrong at the boundary (your DatabaseError) and why (the underlying sqlite error). Future-you debugging at 2 a.m. will thank present-you.
When you genuinely want to suppress the chain — usually because the inner detail leaks implementation noise across an API boundary — use from None:
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 drops the inner frame, and the caller sees only your clean message. Use this sparingly — usually from e is the right call, because dropping the cause throws away debugging information.
5. contextlib.suppress — The "I Really Don't Care" Pattern
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')
…is four lines for a one-liner intent. contextlib.suppress does the same thing inline:
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 genuinely expected and there's nothing to do.
- 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 anti-pattern as try/except Exception: pass, just 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. One decorator, one yield, and you've got a reusable resource-management primitive.
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 multiple errors to report at once — concurrent tasks each failing, 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 strips out all the exceptions of that type, even if they're nested inside the group. Any exceptions not matched stay grouped and bubble up. This is the right primitive for asyncio.TaskGroup, where N tasks can fail in parallel and you want to surface all of them.
Pre-3.11, the workaround is a plain list of errors that you raise as a single exception carrying them as an attribute — uglier but the same idea.
8. __traceback__ and Logging Exceptions
Every exception object carries its traceback as e.__traceback__. You rarely access it directly — instead, you let logging do the work:
import logging logger = logging.getLogger(__name__) try: risky() except Exception: logger.exception("risky() blew up") # ← includes the full traceback in 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 loses the traceback, which is the part that actually 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:
- Race-free: a check followed by an action has a window where the world can change between them. The try/except does the action and reacts to the actual outcome.
- Faster on the happy path: in CPython, taking an exception is expensive, but not taking one is essentially free. If failures are rare, EAFP wins on speed too.
- 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) — non-Pythonic if "name" in user and isinstance(user["name"], str): name = user["name"].strip() else: name = "anonymous" # EAFP — Pythonic 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 is one expression for the happy path. It also handles the case where user["name"] is None (which isinstance(..., str) would catch but in a different style). Both styles are valid; in Python, the second is the default.
The exception: when "looking before" is genuinely cheaper or clearer. Checking if x is None before calling a method on x is fine — it's one comparison and reads naturally.
10. Common Mistakes
1. Bare except: (still)
Catches KeyboardInterrupt and SystemExit. Your Ctrl+C stops working. Don't.
2. except Exception: pass at the top of a function "just in case"
You've now hidden every bug, every typo, every NameError. The function "works" by silently doing nothing. Catch specific types you can actually handle.
3. Silent pass in except blocks
If you must catch, at least log. A swallowed error you never see is a bug you'll spend hours chasing 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 — drops the cause try: parse(payload) except ValueError: raise MyError("bad payload") # __cause__ is None; 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 — must be Exception subclass
Python 2 allowed it. Python 3 doesn't. Always
raise SomeException("message").
6. Exceptions as control flow when an if would do
Catching StopIteration to detect end-of-loop in normal code, or using try around dict access when .get() is right there. Exceptions are cheap when not raised — but they're not the right tool for predictable 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 field missing from data → RequiredFieldError # - elif data[field] is not an instance of expected_type → 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 (e.g. bool is a subclass of int — that's a feature, not a bug, for this exercise).
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) # wrong type: 'age' expected int, got str # missing: 'email' is required
What you built:
- A real hierarchy —
ValidationErrorlets a caller catch "anything validation-y" with oneexcept; the subclasses let them be specific. - Field-aware exception attributes —
e.field,e.expected,e.got— so callers can build structured responses (JSON API error payloads, form error highlights) without re-parsing the message string. - All-errors-at-once — the caller gets every problem in one pass. Compare to "stop on first error", which forces the user to fix → submit → fix → submit indefinitely.
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 a lot more — but the underlying pattern is the one you just built.
What You Learned
- Custom exceptions are two lines and unlock catch-by-type. Define a base class for your app/library; 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 Nonesuppresses it (use sparingly). contextlib.suppress(ExceptionType)for the genuine "ignore this exact error" pattern.@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 — captures the full traceback.- EAFP is Pythonic. Try the happy path; catch what could realistically go wrong.
- 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 half-dozen patterns that cover 90% of real-world regex work.
Practice this
on practicepython.inShort exercises that run in your browser and tell you what your code actually did, not just whether a test passed.