PythonMastery
intermediate 18 min read · lesson 9 of 13 in Python Intermediate

Exceptions: Patterns Beyond the Basics

1 · The lesson

read

You 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.

python
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:

python
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:

python
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:

python
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:

python
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:

python
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:

python
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:

python
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:

python
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:

python
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.

python
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:

python
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:

python
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:

python
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:

python
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.
python
# 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:

python
# 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

python
# 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

python
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.

python
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:

python
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 a list 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
python
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 — ValidationError lets a caller catch "anything validation-y" with one except; 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: else runs on success, finally always runs.
  • Chaining: raise NewError(...) from original preserves the cause in __cause__. from None suppresses it (use sparingly).
  • contextlib.suppress(ExceptionType) for the genuine "ignore this exact error" pattern.
  • @contextmanager turns a try/finally into a with-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:, never pass silently 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.in

Short exercises that run in your browser and tell you what your code actually did, not just whether a test passed.