Python Exceptions Done Right: try/except, else, finally, and Custom Error Types

Exception handling separates robust Python programs from fragile scripts. A practical tour of try/except/else/finally, custom exception hierarchies, chaining, and the patterns that keep failures debuggable.

Every Python program eventually meets a situation it didn't plan for: a file that isn't there, a network call that times out, a string that refuses to become a number. What separates a robust tool from a fragile script is not avoiding these situations — it's handling them deliberately. This post is a practical tour of Python's exception machinery, from the basic try/except block to custom error hierarchies you can build your own libraries on.

By the way: if you read German, our sister blog meine-codereise.de has a friendly beginner introduction to this topic — Exceptions in Python: Fehler sauber behandeln mit try und except. This article picks up roughly where that one leaves off and goes deeper into patterns you'll want in production code.

EAFP: ask forgiveness, not permission

Python culture favors EAFP — "easier to ask forgiveness than permission." Instead of checking whether a key exists before reading it, you read it and handle the failure:

try:
    port = int(config["port"])
except KeyError:
    port = 8080
except ValueError:
    raise SystemExit(f"Invalid port value: {config['port']!r}")

This is not just style. Check-then-act code has race conditions (the file you just checked can vanish before you open it), and it duplicates logic the operation performs anyway. The exception path is the honest path.

Catch narrowly, fail loudly

The most common mistake is the bare except: or the almost-as-bad except Exception: that swallows everything, including bugs. Catch the specific exceptions you can actually do something about, and let the rest propagate. A stack trace at the top level is a feature: it tells you what went wrong and where. A silently swallowed TypeError is a bug you'll hunt for a week.

else and finally: the underused halves

A full try statement has four parts, and the two most people skip are often the most useful:

try:
    f = open(path)
except FileNotFoundError:
    log.warning("No cache file, starting cold")
else:
    # runs only if no exception was raised
    data = parse(f.read())
finally:
    # runs no matter what
    release_lock()

The else block keeps the try body minimal — only the line that can actually fail lives inside it, so you never accidentally catch an exception from code you didn't mean to guard. finally guarantees cleanup even when an exception is on its way up (though for files and locks, a with block is usually cleaner still).

Custom exceptions: your library's error API

When you write anything bigger than a script, define a small exception hierarchy. Callers can then decide how granular they want to be:

class AppError(Exception):
    """Base class for all errors raised by this package."""

class ConfigError(AppError):
    pass

class RetryableError(AppError):
    """Transient failure — safe to retry."""

One base class per package is the golden rule: it lets a caller write except AppError: to handle everything you raise on purpose, while genuine bugs (TypeError, AttributeError) still surface immediately.

Exception chaining: keep the evidence

When you translate a low-level error into a domain-level one, chain it with raise ... from ...:

try:
    raw = fetch(url)
except OSError as exc:
    raise RetryableError(f"fetch failed for {url}") from exc

The traceback now shows both errors, connected by "The above exception was the direct cause…". You lose nothing and gain a readable story. Use raise X from None only when the underlying cause is genuinely noise.

Practical rules of thumb

A few habits that pay off in every codebase: keep try blocks as small as possible; never use exceptions for ordinary control flow across module boundaries; log exceptions with log.exception(...) inside an except block so the traceback is preserved; and re-raise with a bare raise when you only want to observe an error, not handle it.

Exceptions are Python's error-reporting channel, and treating them as an API — narrow catches, meaningful custom types, honest chaining — makes your failures as well-designed as your successes. And if you want to revisit the fundamentals in German first, the meine-codereise.de introduction is a solid place to start.