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

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

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 can choose to catch broad or specific errors:

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

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

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 error, and keep the original 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

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:

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

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')

…takes four lines for a simple idea. contextlib.suppress does the same thing in two:

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

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

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

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

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:

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

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

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

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 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 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 (for example, bool is a subclass of int, but for this exercise, accepting True as an int is fine).
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)
# missing: 'email' is required
# wrong type: 'age' expected int, got str

What you built:


  • A real hierarchy: ValidationError lets a caller catch any validation error with one except, 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: else runs on success, finally always runs.
  • Chaining: raise NewError(...) from original preserves the cause in __cause__. from None hides it (use this rarely).
  • contextlib.suppress(ExceptionType) when you really want to ignore one specific error type.
  • @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 logs the full traceback.
  • EAFP is Pythonic. Try the normal case, and catch the errors that can really happen.
  • 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 few patterns you will use most often.

Practice this

on practicepython.in

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