Exceptions

Python's error handling looks like Java's try/catch, with two twists: the culture is EAFP — "easier to ask forgiveness than permission," i.e. try the operation and handle failure rather than checking first — and the block has four parts whose ordering is worth getting exactly right: try, except, else, finally.

try / exceptelse finallyEAFP propagation

Four blocks, four jobs

Set whether the try body raises, and which error, then trace exactly which blocks run:

Catch narrowly; never silence everything

The single most common mistake is a bare except: that swallows every error — including KeyboardInterrupt — or an except Exception: that swallows every bug (typos included) and hides it. Catch the specific exception you know how to handle and let the rest propagate. Propagation is a feature: an unhandled exception travels up the call stack until something catches it, and if nothing does, Python prints a traceback pinpointing where it came from.

When you need your own error category, subclass Exception: class InvalidRowError(Exception): pass — then raise InvalidRowError("bad line 7") and callers can except InvalidRowError precisely. This is exactly the pattern your Phase 0 capstone uses to flag bad CSV rows.

Chaining, cleanup, and what never to catch

When a handler itself turns one error into another — a parse failure becoming your own ParseError, say — write raise NewError(...) from e rather than a bare raise NewError(...). Both produce a traceback, but only from e records why: Python prints the original error, then the sentence "the above exception was the direct cause of the following exception," and the new error's __cause__ attribute holds e for any code that wants to inspect it. Losing that chain is losing the reason a bug happened, not just the fact that it did.

The four-block trace above already showed which blocks run when the try body raises. The question this section adds: what if the except handler itself raises — while cleaning up, while re-wrapping the error, anything? finally doesn't care whose exception is leaving; it still runs on the way out, every time, even when that exception was born inside the handler and not the try.

Since Python 3.11, ExceptionGroup lets code raise several unrelated errors at once (the classic case: a batch of independent tasks, more than one of which failed) and except* — note the star — matches against the errors inside the group by type, so more than one except* clause can fire for a single raise, one per matching sub-exception.

And the title's last clause — what never to catch. "Catch narrowly" above said not to write except Exception: pass; here is why it doesn't even buy the safety it looks like: KeyboardInterrupt and SystemExit inherit directly from BaseException, not Exception, so Ctrl-C and sys.exit() still get through — while every real bug in the block (a typo, a wrong type) is gone with no trace, a quietly wrong answer instead of a traceback pointing at the line.

Takeaways: try runs risky code; the first matching except handles it; else runs only on success; finally runs always (cleanup). Catch specific exceptions — never a bare except. Uncaught exceptions propagate up the stack and print a traceback. Define domain errors by subclassing Exception.

Guaranteed cleanup in finally is usually better expressed with a with block →