Python Lesson 19 of 71

Exceptions In Depth: Hierarchy, Custom Exceptions & try/except/else/finally

You have met try/except already — enough to stop a missing file from killing your script. That was first aid. This lesson is the anatomy.

The shift is this: exceptions are not accidents, they are an interface. When your function can’t do what its name promises, it has exactly one honest way to say so, and the class it raises is the message. Callers don’t read your error strings — they catch your classes. So raise ValueError("bad port") versus raise TypeError("bad port") is not a style choice; it’s the difference between a caller that can handle you and one that can’t.

Everything here targets Python 3.12+, needs no pip install, and runs the same on macOS, Linux and Windows. Every traceback below is real output, copied from a 3.12 run — not paraphrased. Where 3.12’s wording differs from older Pythons, there’s a note.


Why this matters

Here is the shape of the bug that makes people distrust their own codebase. A function deep in your app raises KeyError. Three frames up, someone wrote except Exception: pass because “it was crashing.” Now the program doesn’t crash. It also doesn’t work — it returns 0, or None, or an empty list, and everything downstream computes a confident wrong answer. Nobody gets a traceback. Nobody gets a log line. Six weeks later a number in a report is wrong and there is no thread to pull.

That bug isn’t caused by not knowing the syntax. It’s caused by not having a policy. The syntax is four keywords you can learn in a minute; the policy is knowing which exception to raise, how wide to catch, where to let it fly, and what to do with the original error when you replace it. That policy is what separates code you can debug at 3 a.m. from code you can only apologise for.

The mental model to carry through everything below: an exception is an object that travels. Three separate things are happening, and beginners blur them together:

Get those three straight and the rest of this lesson is consequences. (This lesson assumes you’re comfortable with functions and the call stack — see Functions, Parameters & Return — and with classes and inheritance, since a custom exception is just a class: OOP: Classes, Objects, Attributes & Methods. The basics of try/except and the file-specific errors live in File I/O & Basic Error Handling.)


The exception hierarchy: what you are actually catching

Every exception in Python is an object whose class inherits, eventually, from BaseException. That is the root. Directly beneath it sit exactly five classes in 3.12, and the split between them is the single most important design decision in the whole hierarchy:

print("BaseException direct:", [c.__name__ for c in BaseException.__subclasses__()])
print("Exception is a BaseException:", issubclass(Exception, BaseException))
BaseException direct: ['BaseExceptionGroup', 'Exception', 'GeneratorExit', 'KeyboardInterrupt', 'SystemExit']
Exception is a BaseException: True

Look at what is beside Exception rather than under it. KeyboardInterrupt, SystemExit and GeneratorExit are deliberately kept out of the Exception subtree, and that is not an accident of taxonomy — it is a safety mechanism built into the class tree.

Class Under Exception? Raised by Why it sits outside Exception
Exception — (it’s the root of them) Your code, the stdlib, everything ordinary This is the one you catch. Every error that means “the program hit a problem”
KeyboardInterrupt The user pressing Ctrl-C It’s not a program error — it’s a human saying stop. Swallowing it makes your program unkillable
SystemExit sys.exit() It’s a request to exit, not a failure. Swallowing it means sys.exit(1) silently does nothing
GeneratorExit A generator being closed It’s the interpreter cleaning up. Swallowing it corrupts generator shutdown
BaseExceptionGroup ❌ (but ExceptionGroup is) raise ExceptionGroup(...) It can wrap KeyboardInterrupt, so it must live outside too. See the last section

So the rule everyone quotes — catch Exception, never BaseException — has a precise reason. except Exception: is a line drawn exactly around “things that went wrong in my program,” deliberately excluding “the user wants out” and “someone called sys.exit()”. Write except BaseException: (or its lazy twin, bare except:) and you erase that line:

# ❌ Both of these swallow Ctrl-C and sys.exit()
try:
    long_running_job()
except BaseException:      # explicit and wrong
    pass

try:
    long_running_job()
except:                    # bare except: IS except BaseException:
    pass

# ✅ Ctrl-C still works; sys.exit() still exits
try:
    long_running_job()
except Exception:              # no 'as e' needed - log.exception grabs it
    log.exception("job failed")
    raise

A user hammering Ctrl-C on the first version watches their terminal ignore them, because each interrupt lands inside the try, gets caught, and gets discarded. That’s not a hypothetical — it’s the classic “why won’t this script die?” support ticket.

The families under Exception

Under Exception the tree is mostly flat, with a few important groupings. Those groupings matter because catching a parent catches every child — sometimes exactly what you want, sometimes a trap:

# __mro__ is the class's ancestry, root last. Slice off [0] (the class itself).
for cls in (KeyError, IndexError, ZeroDivisionError,
            FileNotFoundError, NotImplementedError, UnicodeDecodeError):
    print(f"{cls.__name__:20} -> {' -> '.join(k.__name__ for k in cls.__mro__[1:])}")
KeyError             -> LookupError -> Exception -> BaseException -> object
IndexError           -> LookupError -> Exception -> BaseException -> object
ZeroDivisionError    -> ArithmeticError -> Exception -> BaseException -> object
FileNotFoundError    -> OSError -> Exception -> BaseException -> object
NotImplementedError  -> RuntimeError -> Exception -> BaseException -> object
UnicodeDecodeError   -> UnicodeError -> ValueError -> Exception -> BaseException -> object
Family parent Children you’ll meet Catch the parent when…
LookupError KeyError, IndexError You’re indexing something and don’t care which kind failed
ArithmeticError ZeroDivisionError, OverflowError, FloatingPointError Any numeric failure is equally recoverable
OSError FileNotFoundError, PermissionError, IsADirectoryError, TimeoutError, ConnectionError Any OS-level failure means “fall back”. Carries .errno, .strerror, .filename
ValueError UnicodeErrorUnicodeDecodeError/UnicodeEncodeError ⚠️ Note this: a decoding error is a ValueError, not an OSError
RuntimeError NotImplementedError, RecursionError Rarely — these mean very different things
ImportError ModuleNotFoundError An optional dependency is missing

Two rows there are worth pinning to your wall. UnicodeDecodeError is a ValueError, so code that “handles all file errors” with except OSError: sails straight past a badly encoded file and crashes. And NotImplementedError is a RuntimeError, which surprises people who assume it’s a sibling of TypeError.

What an exception object actually IS

An exception is an ordinary object. It has a class, it has attributes, and you can poke at it:

try:
    int("eighty")
except ValueError as e:
    print("type   :", type(e).__name__)
    print("args   :", e.args)
    print("str    :", str(e))
    print("isinst :", isinstance(e, Exception), isinstance(e, BaseException))
type   : ValueError
args   : ("invalid literal for int() with base 10: 'eighty'",)
str    : invalid literal for int() with base 10: 'eighty'
isinst : True True

.args is the tuple of everything you passed to the constructor. str(e) is args[0] when there’s exactly one argument — which is why the message prints cleanly — and the whole tuple’s repr when there are several. That detail bites later, so watch it:

e1 = ValueError("bad port")
e2 = ValueError("bad port", 70000)          # TWO args
print("one arg  -> args:", e1.args, "| str:", str(e1))
print("two args -> args:", e2.args, "| str:", str(e2))
one arg  -> args: ('bad port',) | str: bad port
two args -> args: ('bad port', 70000) | str: ('bad port', 70000)

With two args, str(e) becomes ('bad port', 70000) — a tuple repr in the middle of your traceback. If you want structured data on an exception, use named attributes, not extra positional args. OSError is the model to copy — it does both:

try:
    open("/nope/missing.txt")
except OSError as e:
    print("errno:", e.errno, "| strerror:", e.strerror, "| filename:", e.filename)
    print("args :", e.args)
errno: 2 | strerror: No such file or directory | filename: /nope/missing.txt
args : (2, 'No such file or directory')
Attribute Type What it holds When you use it
.args tuple Everything passed to the constructor Rarely read directly; str(e) is friendlier
str(e) str args[0] if len==1, else the tuple’s repr Logging, user-facing messages
type(e) class The exception class Deciding what happened; type(e).__name__ for logs
.__traceback__ traceback | None The frame chain — None until it’s raised traceback module, re-raising
.__context__ exception | None The error being handled when this one was raised — set automatically Debugging “what was going on?”
.__cause__ exception | None The error you explicitly blamed via raise ... from e Deliberate chaining
.__suppress_context__ bool True after raise ... from None Hiding an implementation detail
.__notes__ list[str] Free-text notes (3.11+) via e.add_note("...") Adding context without a new exception
.errno .strerror .filename OSError family only Reacting to a specific OS failure

The built-ins: which exception is the RIGHT one to raise

Most beginners raise Exception("something went wrong") and move on. That’s the equivalent of a doctor writing “unwell” on a chart. The class is the diagnosis, and callers dispatch on it, so picking the right one is real work with a real payoff.

The test to apply: if a caller wanted to handle only this kind of failure, what would they write in their except? Raise that.

Exception Raise it when Don’t raise it when Canonical example
ValueError The type is right but the value is wrong The type is wrong → TypeError int("eighty"); a port of 70000; an empty username
TypeError The type itself is wrong for the operation The value is merely out of range len(5); "a" + 1; passing None where a str is required
KeyError A mapping key is absent A list index → IndexError {"a": 1}["b"]
IndexError A sequence index is out of range A dict key → KeyError [1, 2][9]
AttributeError An attribute doesn’t exist on the object The object is the wrong type entirely → TypeError "abc".push("d"); a typo’d .amuont
FileNotFoundError A path doesn’t exist Any other OS failure → the specific OSError child open("nope.txt")
OSError An OS-level operation failed and no child fits A child class does fit — be specific Disk full (errno 28)
ZeroDivisionError Division/modulo by zero — (you rarely raise it yourself) 10 / 0
StopIteration An iterator is exhausted ⚠️ Inside a generator — see the RuntimeError trap below next(iter([]))
RuntimeError Something went wrong that no other class describes A better class exists — this is the “misc” bin “generator raised StopIteration”; bad internal state
NotImplementedError An abstract method must be overridden by a subclass The feature is merely unfinished — say so in a comment def area(self): raise NotImplementedError

The ValueError versus TypeError distinction is the one people get wrong most often, so here it is as a single rule: TypeError means “I can’t work with this kind of thing at all”; ValueError means “I can work with this kind of thing, but not with this one.” int("eighty") is a ValueError because "eighty" is a perfectly good strint() accepts strings — it just isn’t a numeral. int(None) is a TypeError because int() doesn’t take None at all. Watch both from the same function:

def parse_port(raw):
    """Parse a port number. Raises TypeError or ValueError."""
    if not isinstance(raw, str):
        raise TypeError(f"port must be a str, got {type(raw).__name__}")
    port = int(raw)                      # raises ValueError on "eighty"
    if not (1 <= port <= 65535):
        raise ValueError(f"port {port} out of range 1-65535")
    return port

for raw in ["8080", "eighty", "70000", 8080, None]:
    try:
        print(f"{raw!r:10} -> {parse_port(raw)}")
    except (TypeError, ValueError) as e:
        print(f"{raw!r:10} -> {type(e).__name__}: {e}")
'8080'     -> 8080
'eighty'   -> ValueError: invalid literal for int() with base 10: 'eighty'
'70000'    -> ValueError: port 70000 out of range 1-65535
8080       -> TypeError: port must be a str, got int
None       -> TypeError: port must be a str, got NoneType

Notice that the "eighty" case cost you nothing: int() already raises exactly the right class with an excellent message, so you just let it out. Reusing a built-in’s exception is better than wrapping it in your own. Only two of the five lines needed you to raise anything.

⚠️ NotImplementedError vs NotImplemented. These are unrelated and the similarity is a genuine Python wart. NotImplementedError is an exception class you raise. NotImplemented is a singleton value you return from __eq__/__lt__/__add__ to tell Python “try the reflected operation.” Raising NotImplemented gives you TypeError: exceptions must derive from BaseException; returning NotImplementedError from __eq__ makes every comparison silently truthy.


raise: signalling failure without lying about where it came from

raise has four forms, and the differences between them are exactly where tracebacks get destroyed.

Form What it does Use when
raise ValueError("msg") Raise a new instance The default. You’re reporting a failure
raise ValueError Raise the class — Python instantiates it with no args Never, really. str(e) is empty; give it a message
raise (bare) Re-raise the exception currently being handled, traceback intact Inside except — you logged/cleaned up and want it to keep flying
raise X from e Raise X, setting __cause__ = e Translating a low-level error into your own. Next section

The bare-class form is worth seeing once so you recognise it:

try:
    raise ValueError            # class, not instance -> Python calls ValueError()
except ValueError as e:
    print("instantiated for you:", repr(e), "| args:", e.args)
instantiated for you: ValueError() | args: ()

Python quietly called the class for you. It works, but you’ve thrown away the chance to say why — uncaught, the traceback’s last line is just ValueError, with no colon and no message. Always pass one.

Bare raise is the re-raise you want

The common need: catch an error, record it, then let it continue. Beginners write raise e. The right answer is a bare raise, and the difference shows up in the traceback.

# reraise.py — log it, then let it fly
import logging
logging.basicConfig(level=logging.ERROR, format="%(levelname)s %(message)s")

def parse(raw):
    return int(raw)                    # line 6 - the REAL origin

def handler(raw):
    try:
        return parse(raw)
    except ValueError:
        logging.error("bad input %r, re-raising", raw)
        raise                          # bare: no new frame, no lie

handler("eighty")
ERROR bad input 'eighty', re-raising
Traceback (most recent call last):
  File "/home/vinod/python-exc-lab/reraise.py", line 15, in <module>
    handler("eighty")
  File "/home/vinod/python-exc-lab/reraise.py", line 10, in handler
    return parse(raw)
           ^^^^^^^^^^
  File "/home/vinod/python-exc-lab/reraise.py", line 6, in parse
    return int(raw)                    # line 6 - the REAL origin
           ^^^^^^^^
ValueError: invalid literal for int() with base 10: 'eighty'

Three frames, ending precisely at int(raw) on line 6. That’s the truth. Now change one lineraise becomes raise e (and except ValueError: becomes except ValueError as e:) — and re-run the same file:

ERROR bad input 'eighty', re-raising
Traceback (most recent call last):
  File "/home/vinod/python-exc-lab/raise_e.py", line 15, in <module>
    handler("eighty")
  File "/home/vinod/python-exc-lab/raise_e.py", line 13, in handler
    raise e                        # <- the ONLY change from reraise.py
    ^^^^^^^
  File "/home/vinod/python-exc-lab/raise_e.py", line 10, in handler
    return parse(raw)
           ^^^^^^^^^^
  File "/home/vinod/python-exc-lab/raise_e.py", line 6, in parse
    return int(raw)                    # line 6 - the REAL origin
           ^^^^^^^^
ValueError: invalid literal for int() with base 10: 'eighty'

Be precise about what went wrong here, because the folklore overstates it. raise e did not erase the traceback — the origin at line 6 is still there, because the traceback is attached to the exception object itself. What it did was insert a bogus frame: line 13, in handler / raise e, pointing at your logging code as though it were part of the failure path. Four frames now, where three was the truth. On a toy example that’s noise. In a real service, where the same exception gets logged and re-raised at four layers, you get four fake frames interleaved with the real ones, and the traceback stops being readable. Bare raise costs one character less and adds nothing.

⚠️ The genuinely dangerous re-raise is raise e from a different context. Store an exception, re-raise it inside an unrelated except block, and Python helpfully chains the two — producing a traceback that claims a KeyError in your cleanup code led to a ValueError from your parser. They’re unrelated; the “During handling of the above exception” line is a lie that will cost you an hour.

And the real traceback-killer is neither of those — it’s rebuilding the exception by hand:

except ValueError as e:
    raise ValueError(str(e))          # NEW object: your own class/args/attrs all gone

You’ve thrown away the original class (maybe it was a UnicodeDecodeError with an .encoding), thrown away .errno and .filename if it was an OSError, and kept only the message. The origin frames survive only because Python auto-chains the context — and if anyone adds from None to “clean up the traceback”, the evidence is gone for good.


How an exception travels: propagation and the traceback

Here is the whole mechanism in one program. One raise, three frames, one handler:

# deep.py — one raise, three frames of unwinding
def read_field(cfg):
    return int(cfg["port"])        # <- the raise happens HERE

def parse_config(cfg):
    return read_field(cfg)         # no handler: unwinds

def start_app(cfg):
    try:
        return parse_config(cfg)   # <- the matching handler is HERE
    except ValueError as e:
        print("start_app caught:", type(e).__name__, "-", e)
        return 80

print("port =", start_app({"port": "eighty"}))
start_app caught: ValueError - invalid literal for int() with base 10: 'eighty'
port = 80

int() raised inside read_field. There was no handler there, so Python popped that frame and asked parse_config — no handler either — popped it, and asked start_app, which had one that matched. Two frames were abandoned mid-statement, their local variables discarded, and control resumed in the except block. That’s propagation: a search up the stack, one frame at a time, testing classes.

Left-to-right diagram of Python exception propagation: start_app() with the only try/except calls down through parse_config() to read_field(), where int() raises a ValueError; the exception object carries .args and traceback; Python unwinds three frames asking at each whether an except clause matches the class, first match winning; the outcome is either a handled branch where else is skipped and finally still runs, an uncaught branch that prints a traceback and exits 1, or a chained re-raise setting cause

Follow the arrows: the call descends left to right into read_field(), int(cfg['port']) raises, and then the search runs back up the same three frames asking one question at each — does an except here match this class? The badges mark the six places learners lose time. Catch Exception, never BaseException, or you swallow Ctrl-C (1). The class you raise is your API, so pick the true one (2). The first matching handler wins, which is why a superclass listed first turns the specific handler below it into dead code (3). else runs only when nothing was raised, while finally runs no matter what — including on return (4). If nothing matches anywhere, Python prints the traceback and exits 1 (5). And raising inside an except links the old error automatically, which raise ... from e upgrades from a coincidence into a stated cause (6).

Now delete the handler and let it escape:

# deep_uncaught.py — nobody handles it: the traceback IS the unwind path
def read_field(cfg):
    return int(cfg["port"])        # 3. the raise happens here

def parse_config(cfg):
    return read_field(cfg)         # 2. no handler here

def start_app(cfg):
    return parse_config(cfg)       # 1. no handler here either

start_app({"port": "eighty"})      # 0. module level - nothing left to unwind to
Traceback (most recent call last):
  File "/home/vinod/python-exc-lab/deep_uncaught.py", line 11, in <module>
    start_app({"port": "eighty"})      # 0. module level - nothing left to unwind to
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/vinod/python-exc-lab/deep_uncaught.py", line 9, in start_app
    return parse_config(cfg)       # 1. no handler here either
           ^^^^^^^^^^^^^^^^^
  File "/home/vinod/python-exc-lab/deep_uncaught.py", line 6, in parse_config
    return read_field(cfg)         # 2. no handler here
           ^^^^^^^^^^^^^^^
  File "/home/vinod/python-exc-lab/deep_uncaught.py", line 3, in read_field
    return int(cfg["port"])        # 3. the raise happens here
           ^^^^^^^^^^^^^^^^
ValueError: invalid literal for int() with base 10: 'eighty'

The process exits with status 1, and the traceback goes to stderr, not stdout (which is why piping to grep seems to lose it — use 2>&1).

Reading a traceback properly

Beginners read tracebacks top-down, panic at the unfamiliar filenames, and give up. Read it bottom-up:

Where What it tells you Read it…
Last line ExceptionType: messagewhat went wrong, and the class you’d catch First. Always.
Line above it The innermost frame — where it broke Second
^^^^ markers The exact sub-expression that failed (3.11+) With the frame above
Frames going up The call path that got you there, caller above callee Third, if the innermost frame isn’t enough
First line Traceback (most recent call last): — a header, not information Never
During handling… / The above exception… A chain boundary — there are two exceptions here See the next section

“Most recent call last” is the whole key, and it’s stated right there in the header. The bottom is where you are; the top is how you got there. In the trace above, the answer is on the last line (ValueError: invalid literal) and the scene of the crime is directly above it (line 3, in read_field). Lines 11, 9 and 6 are just the road.

Those ^^^^ carets are fine-grained error locations, added in 3.11. They matter most on a dense line: a["x"] + b["y"] will underline exactly which subscript blew up, so you no longer have to bisect the line by hand. On Python 3.10 and earlier you get the line but not the carets — one of the best reasons to be on 3.11+.

One more 3.12 gift, visible later in the lab: AttributeError and NameError now suggest corrections — 'Row' object has no attribute 'amuont'. Did you mean: 'amount'? (3.10+). Read the last line and it often fixes itself.


The full statement: try / except / else / finally

The complete statement has four clauses. Most people use two. The other two are where the design lives.

def probe(n):
    print(f"--- probe({n!r})")
    try:
        print("  try      : start")
        r = 10 / n
        print("  try      : ok ->", r)
    except ZeroDivisionError as e:
        print("  except   : caught", type(e).__name__)
    else:
        print("  else     : no exception ran")
    finally:
        print("  finally  : always")
    print("  after    : function continues")

probe(2)
probe(0)
--- probe(2)
  try      : start
  try      : ok -> 5.0
  else     : no exception ran
  finally  : always
  after    : function continues
--- probe(0)
  try      : start
  except   : caught ZeroDivisionError
  finally  : always
  after    : function continues
Clause Runs when Skipped when Notes
try: Always Keep it small — only the risky call
except X: isinstance(exc, X) No exception, or a different class Order matters. Several allowed
else: try completed with no exception Any exception was raised Runs before finally. Not protected by the except
finally: Always — success, handled, unhandled, return, break, continue Basically never Cleanup. Runs last

The execution order in one table — every path through the statement:

Scenario try body except else finally Then
No exception runs fully ✅ runs ✅ runs continues after
Exception, matched runs to the raise ✅ runs ❌ skipped ✅ runs continues after
Exception, unmatched runs to the raise ❌ skipped ✅ runs propagates up
Exception raised in else runs fully not caught here raises ✅ runs propagates up
Exception raised in except runs to the raise raises ❌ skipped ✅ runs propagates (chained via __context__)
return in try runs to the return ❌ skipped ✅ runs returns the try value
raise in finally ✅ runs new exception wins; old becomes __context__
return in finally (whatever happened) (as normal) (as normal) ✅ runs ⚠️ its value wins; any live exception is discarded

Why else beats putting everything in try

else looks pointless until you see what it prevents. Compare:

# ❌ The success path is INSIDE the try
try:
    port = int(cfg["port"])
    conn = connect(host, port)          # if THIS raises ValueError...
    conn.send(payload)
except ValueError:
    port = 80                           # ...you silently "handle" it as a bad port

# ✅ try holds only the risky call; the success path is in else
try:
    port = int(cfg["port"])
except ValueError:
    port = 80
else:
    conn = connect(host, port)          # a ValueError here is NOT swallowed
    conn.send(payload)

In the first version, the except ValueError: is a net stretched under three statements. If connect() happens to raise a ValueError deep in a socket library — for a completely unrelated reason — you catch it, set port = 80, and carry on as though the config were bad. You’ve mislabelled someone else’s bug as your own, and the real error is gone.

else fixes that by shrinking the net to exactly the line you’re worried about. That’s the rule: try holds the operation that might fail; else holds everything you do afterwards. The bigger your try block, the more unrelated failures your except will misclassify.

finally always runs — even on return

finally is a promise: this code runs on the way out, whichever exit you take. Including return:

def ok_finally():
    try:
        return "from try"
    finally:
        print("  cleanup runs, return value already computed")

print("ok_finally() ->", ok_finally())
  cleanup runs, return value already computed
ok_finally() -> from try

The return value is computed, then finally runs, then the function actually returns. Which raises the obvious question — what if finally changes things?

def eval_order():
    x = "original"
    try:
        return x
    finally:
        x = "mutated"    # too late
print("eval_order() ->", eval_order())
eval_order() -> original

Too late — return x already evaluated x and stashed the value. Rebinding the name in finally changes nothing. (Mutating the object would still be visible, since the stashed value is a reference.)

⚠️ The finally-swallows-a-return gotcha

Here is the one that eats bugs. A return inside finally doesn’t just override the try’s return — it discards an in-flight exception entirely:

def swallow():
    try:
        return "from try"
    finally:
        return "from finally"     # <-- overrides!

def swallow_exc():
    try:
        raise ValueError("boom")
    finally:
        return "finally ate the exception"

print("swallow()     ->", swallow())
print("swallow_exc() ->", swallow_exc())
swallow()     -> from finally
swallow_exc() -> finally ate the exception

Read that second line again. A ValueError was raised, was never caught by anything, and the function returned a string as if nothing happened. No traceback, no log, no clue. The return in finally acted as an invisible bare except: — and it’s harder to spot in review, because there’s no except keyword anywhere near it.

The same applies to break and continue inside finally in a loop: they win, and any propagating exception is dropped.

Statement in finally Effect on a pending return Effect on a propagating exception
Nothing (just cleanup) ✅ Preserved ✅ Preserved — keeps propagating
return ⚠️ Overrides it ⚠️ Silently discarded
break / continue (in a loop) ⚠️ Overrides it ⚠️ Silently discarded
raise Replaced by the new exception Replaced (old one becomes __context__)

The rule is blunt: never return, break or continue from a finally block. Linters flag it (ruff calls it B012, pylint calls it lost-exception), and there is no case where it’s clearer than the alternative. finally is for cleanup — closing, releasing, restoring. Nothing else.

And a reminder from the file lesson: if finally is only there to call .close(), you want a with block instead. with is try/finally with the boilerplate removed.


Catching well: ordering, tuples, and the unreachable handler

A handler matches with isinstance. Everything below follows from that one sentence.

Ordering matters, and getting it wrong is silent. Python tests handlers top to bottom and stops at the first match. A superclass listed first therefore shadows every subclass below it:

# ❌ FileNotFoundError is a subclass of OSError -> second branch is DEAD CODE
try:
    open("/nope/x.txt")
except OSError:
    print("1) OSError won - FileNotFoundError branch is DEAD code")
except FileNotFoundError:
    print("1) never printed")

# ✅ specific first
try:
    open("/nope/x.txt")
except FileNotFoundError:
    print("2) specific first -> FileNotFoundError")
except OSError:
    print("2) not reached")
1) OSError won - FileNotFoundError branch is DEAD code
2) specific first -> FileNotFoundError

No warning, no error — the first version’s second branch simply never runs, forever. The rule: subclass before superclass, always. And since Exception is everyone’s superclass, except Exception: must be last, if it appears at all.

Catch several classes with a tuple — note the parentheses are required:

for raw in ["12", "eighty", None]:
    try:
        print("->", int(raw))
    except (ValueError, TypeError) as e:      # a TUPLE, not two names
        print(f"{type(e).__name__}: {e}")
-> 12
ValueError: invalid literal for int() with base 10: 'eighty'
TypeError: int() argument must be a string, a bytes-like object or a real number, not 'NoneType'
Pattern Meaning Watch out
except ValueError: This class and every subclass Subclasses come along for free
except (ValueError, TypeError): Either one Parens required; except ValueError, TypeError: is a SyntaxError in Py3
except (ValueError, TypeError) as e: Either, bound to e e is deleted at the end of the block (see below)
except Exception: Every ordinary error Must be last. Only with a raise or a real log
except BaseException: / except: ⚠️ Everything, incl. Ctrl-C Never
except SomeError: (specific, no as) You don’t need the object Perfectly fine — don’t bind what you won’t use

⚠️ as e unbinds itself. At the end of an except X as e: block, Python deletes e — it’s cleaned up to avoid a reference cycle (the exception references the frame, which references e). So this fails:

try:
    int("eighty")
except ValueError as e:
    pass
print(e)          # NameError: name 'e' is not defined

If you need the exception after the block, assign it to another name inside: err = e.


Exception chaining: __context__, __cause__, and raise ... from

When you raise a new exception while handling an old one, Python keeps both. This is chaining, and it produces the two traceback sentences everyone has seen and nobody has read carefully. They mean different things.

Implicit: __context__ — “During handling…”

Raise inside an except and Python automatically records what you were handling:

def load_port(raw):
    try:
        return int(raw)
    except ValueError:
        raise RuntimeError("config is broken")     # no 'from'

load_port("eighty")
Traceback (most recent call last):
  File "/home/vinod/python-exc-lab/implicit.py", line 3, in load_port
    return int(raw)
           ^^^^^^^^
ValueError: invalid literal for int() with base 10: 'eighty'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/home/vinod/python-exc-lab/implicit.py", line 7, in <module>
    load_port("eighty")
  File "/home/vinod/python-exc-lab/implicit.py", line 5, in load_port
    raise RuntimeError("config is broken")
RuntimeError: config is broken

“During handling of the above exception, another exception occurred” is Python saying: these two might be related, or the second might be a bug in your handler — I don’t know, so here’s both. It’s a statement of coincidence in time, nothing more. You didn’t ask for it; you get it for free via __context__.

That neutrality is exactly right, because this message is also how you find bugs in your error handling — a KeyError raised inside an except FileNotFoundError: block shows up here, and it’s genuinely unrelated.

Explicit: __cause__ — “The above exception was the direct cause…”

Add from e and you upgrade the coincidence into a claim:

def load_port(raw):
    try:
        return int(raw)
    except ValueError as e:
        raise RuntimeError("config is broken") from e     # <- from e

load_port("eighty")
Traceback (most recent call last):
  File "/home/vinod/python-exc-lab/explicit.py", line 3, in load_port
    return int(raw)
           ^^^^^^^^
ValueError: invalid literal for int() with base 10: 'eighty'

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/home/vinod/python-exc-lab/explicit.py", line 7, in <module>
    load_port("eighty")
  File "/home/vinod/python-exc-lab/explicit.py", line 5, in load_port
    raise RuntimeError("config is broken") from e
RuntimeError: config is broken

Same two exceptions, same two tracebacks, one different sentence: “The above exception was the direct cause of the following exception.” You have told the reader — and __cause__ has told any tooling — the first one is why the second one exists. That’s the whole difference, and it’s worth two characters of typing.

Suppressed: from None

from None sets __suppress_context__ and prints only your exception:

def get_field(cfg, name):
    try:
        return cfg[name]
    except KeyError:
        raise MissingFieldError(name) from None      # KeyError is an impl detail
Traceback (most recent call last):
  File "/home/vinod/python-exc-lab/show_none.py", line 3, in <module>
    get_field({"port": "80"}, "host")
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/vinod/python-exc-lab/config.py", line 10, in get_field
    raise MissingFieldError(name) from None
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
errors.MissingFieldError: required field 'host' is missing

Clean — and that cleanliness is the danger. from None deletes evidence. It’s right here because “the config dict is a dict” is an implementation detail your caller shouldn’t have to see; the KeyError adds nothing. It is wrong any time the original error carried information — a UnicodeDecodeError’s byte position, an OSError’s errno — and it is always wrong when your motive is “the traceback was too long.”

Form Sets Traceback says Use when
raise X inside except __context__ (automatic) “During handling of the above exception, another exception occurred” You didn’t think about it — the default
raise X from e __cause__ (and __context__) “The above exception was the direct cause of the following exception” The first error explains the second. Translating a low-level error into yours
raise X from None __suppress_context__ = True Nothing — only X is shown The original is a genuine implementation detail
raise (bare) Nothing — same exception Unchanged, no chain Re-raising after logging

When to chain vs. when to let it fly. Don’t wrap an exception just to have your own class. Wrap when you’re crossing a boundary — when the low-level error would leak an implementation detail your caller can’t act on. Your load_config() raising KeyError tells callers you used a dict; if you switch to TOML they get a different exception and their code breaks. Raising ConfigError from e gives them a stable name to catch and keeps the KeyError visible for you. Inside a single module, don’t bother — let it fly.

add_note() — context without a new exception

Sometimes you don’t want to wrap at all; you just want to attach a fact. Python 3.11+ has add_note():

try:
    int("eighty")
except ValueError as e:
    e.add_note("while reading config key 'port'")
    raise
Traceback (most recent call last):
  File "/home/vinod/python-exc-lab/notes2.py", line 2, in <module>
    int("eighty")
ValueError: invalid literal for int() with base 10: 'eighty'
while reading config key 'port'

The note prints right under the exception line. Original class, original traceback, extra context, no chaining, no new object. For “which record was I processing?” this is often better than wrapping — the class stays honest and callers’ except ValueError: still works.


Custom exceptions: designing your app’s error surface

A custom exception is just a class that inherits from Exception. The whole feature:

class AppError(Exception):
    """Base for every error this app raises on purpose."""

That’s it — no body needed beyond a docstring. But the design around it is what earns its keep.

The one rule: a per-app base class

Give your app one base class, and inherit every error you raise from it. That single decision hands your callers something they cannot otherwise have: a way to catch your failures and nothing else.

# errors.py — one base class = one name your callers can catch
class AppError(Exception):
    """Base for every error this app raises on purpose."""

class ConfigError(AppError):
    """The config could not be understood."""

class FieldError(ConfigError):
    """A single field failed validation. Carries the context."""
    def __init__(self, field, value, reason):
        self.field = field
        self.value = value
        self.reason = reason
        super().__init__(f"{field}={value!r}: {reason}")   # <- sets .args and str()

class MissingFieldError(ConfigError):
    def __init__(self, field):
        self.field = field
        super().__init__(f"required field {field!r} is missing")

Because FieldError inherits from ConfigError which inherits from AppError, one object answers to three names:

print([c.__name__ for c in FieldError.__mro__])
['FieldError', 'ConfigError', 'AppError', 'Exception', 'BaseException', 'object']

That ancestry hands the caller a dial, not a switch — they choose how precisely to care, and each level catches everything below it:

for which in ("field", "config", "auth"):
    try:
        load(which)
    except FieldError as e:            # most specific: I know about fields
        print(f"FieldError  -> field={e.field} value={e.value!r} reason={e.reason}")
    except ConfigError as e:           # any config problem
        print(f"ConfigError -> {e}")
    except AppError as e:              # anything this app raises on purpose
        print(f"AppError    -> {e}")
FieldError  -> field=port value='eighty' reason=must be an integer
ConfigError -> file is not valid TOML
AppError    -> token expired

Three different classes, three levels of precision, and the AuthError — which the caller has never heard of — still landed in the AppError branch. Without AppError, a caller wanting to handle “anything your library does wrong” has to write except (FieldError, MissingFieldError, ConfigError, AuthError, ...) — and re-edit that tuple every time you add an error class. With it, except AppError: covers your whole surface forever, including errors you haven’t invented yet. Every serious library does this: requests has RequestException, sqlalchemy has SQLAlchemyError.

Adding attributes — and the super().__init__() detail

Notice FieldError stores field, value and reason as attributes and passes one formatted string to super().__init__(). That’s the pattern, and both halves matter:

Forget the super() call and you get something worse than an empty message:

class NoSuper(Exception):
    def __init__(self, field, value, reason):
        self.field, self.value, self.reason = field, value, reason
        # no super().__init__(...)

b = NoSuper("port", "eighty", "must be an integer")
print("NoSuper str :", repr(str(b)), "| args:", b.args)
NoSuper str : "('port', 'eighty', 'must be an integer')" | args: ('port', 'eighty', 'must be an integer')

BaseException.__new__ set .args from the constructor arguments behind your back, so str(e) renders the raw tuple. Your traceback’s last line becomes NoSuper: ('port', 'eighty', 'must be an integer') instead of a sentence. Always call super().__init__(<one readable message>).

Also worth knowing: custom exceptions print module-qualified in tracebacks — errors.FieldError: port='eighty': must be an integer, not just FieldError. Built-ins come from builtins and are shown bare. That prefix is a feature; it tells you whose error it is.

When a custom exception beats a built-in

Not every failure deserves a class. The test: will anyone ever catch this specifically? If no, use a built-in.

Situation Raise Why
A caller passed a bad value ValueError Universally understood; a custom class adds nothing
A caller passed the wrong type TypeError Same
Your library rejects input, and callers must distinguish it from stdlib errors MyLibError(ValueError) ✅ Best of both: except ValueError: still works, except MyLibError: is precise
The error carries structured data (field, retry_after, status) Custom Attributes beat regex-ing a message
Callers should retry this but not that Custom (TransientError / PermanentError) The class is the retry policy
You’re crossing a module/library boundary Custom, from e Hides implementation detail, keeps the cause
Internal helper, same module, nobody catches it Built-in A class nobody catches is noise

That third row is a genuinely nice trick — multiple inheritance from a built-in:

class MyLibError(Exception): ...
class MyLibValueError(MyLibError, ValueError): ...   # both!

Existing code that catches ValueError keeps working; new code can catch MyLibError for precision. You get to add a hierarchy without breaking anyone.

⚠️ Your exception must derive from BaseException. Forget it and the failure is confusing:

class AppError:               # forgot (Exception)
    pass
raise AppError("nope")
TypeError: AppError() takes no arguments

That message is a red herring — it’s object.__init__ complaining, because a plain class doesn’t accept a message. Drop the argument and you get the honest one: TypeError: exceptions must derive from BaseException. The fix is the same either way: class AppError(Exception):.


EAFP, LBYL, and the anti-patterns that hide bugs

Python has a name for its preferred style: EAFPEasier to Ask Forgiveness than Permission. Try the thing; handle the failure. The alternative, LBYL (Look Before You Leap), checks first.

You met this for files, where LBYL has a race condition. Here’s the argument that applies everywhere — your check is not the same as the operation:

d = {"port": " 8080 "}          # note the whitespace

# ❌ LBYL — the guard disagrees with the operation
if "port" in d and d["port"].isdigit():
    p = int(d["port"])
else:
    p = 80
print("LBYL ->", p)

# ✅ EAFP — the operation is its own guard
try:
    p = int(d["port"])
except (KeyError, ValueError, TypeError):
    p = 80
print("EAFP ->", p)
LBYL -> 80
EAFP -> 8080

The LBYL version fell back to 80 on a perfectly valid port, because isdigit() and int() disagree:

int(' 8080 ') = 8080
'8080'.isdigit() = True | ' 8080 '.isdigit() = False
'-5'.isdigit() = False | int('-5') = -5

int() strips whitespace and accepts a leading -; isdigit() does neither. Your guard is an approximation of the operation, and every approximation is wrong somewhere. EAFP has no approximation — the operation decides.

LBYL (if first) EAFP (try it)
Operations 2+ (check, then do) 1
Race conditions ⚠️ TOCTOU — state can change between check and use ✅ None — atomic
Guard drift ⚠️ The check can disagree with the operation (isdigit vs int) ✅ Impossible
Duck typing isinstance checks reject valid look-alikes ✅ Works with anything that behaves right
Cost when it succeeds Pays for the check every time try is ~free when nothing raises
Cost when it fails Cheap Raising is relatively expensive (~µs)
Readable when… You’re branching (“if config exists, load it, else generate”) You’re guarding (“do it, cope with failure”)
Pythonic? Sometimes Usually

The honest caveat is that last-but-one row: exceptions are cheap to set up and relatively expensive to raise. In a hot loop where failure is the common case (say, 90% of lookups miss), LBYL or dict.get() will be faster. When failure is the exception — which is the normal case, and the reason for the name — EAFP wins on both speed and correctness.

The anti-patterns

Anti-pattern What actually happens Do this instead
except: (bare) Catches BaseException: your typos, Ctrl-C, sys.exit() except Exception: at minimum; a specific class ideally
except Exception: pass Every failure becomes silence. The wrong answer looks like the right one Handle it, log it (log.exception), or don’t catch it
except Exception as e: print(e) Prints invalid literal... with no traceback and no location log.exception("...") — it includes the traceback
A giant try: block The except becomes a net under 20 statements and misclassifies unrelated errors Small try, success path in else
except Exception: to catch one thing Spares Ctrl-C, but still swallows MemoryError, AttributeError and your typos Catch the class you have a plan for
raise ValueError(str(e)) New object; class, .errno, .args structure all lost raise MyError(...) from e
return inside finally ⚠️ Silently discards a propagating exception Never. finally is for cleanup only
Superclass except first Everything below it is unreachable dead code Subclass first, Exception last
Exceptions as normal control flow Slow, unreadable, hides real errors Use if. (Except where it’s idiomatic — below)

except Exception: pass deserves its own paragraph, because it’s the most common one and it is almost never what the author meant. They meant “this failure is acceptable and I want to continue.” What they wrote is “any failure is acceptable” — including AttributeError from a typo, MemoryError from a runaway allocation, and NameError from a variable that doesn’t exist. If a failure really is acceptable, name it: except FileNotFoundError: pass is a statement of intent that a reviewer can agree or disagree with. except Exception: pass is a refusal to think, and it’s untestable — you cannot write a test proving it does the right thing, because it does the same thing for everything.

Where exceptions-as-control-flow IS idiomatic. The advice “don’t use exceptions for control flow” is real but overstated, because Python’s own iterator protocol is built on exactly that. StopIteration is not an error — it’s how every for loop ends:

it = iter([1, 2])
print(next(it), next(it))
try:
    next(it)
except StopIteration:
    print("exhausted - this is NORMAL, not a failure")
1 2
exhausted - this is NORMAL, not a failure

Every for loop you have ever written catches a StopIteration on your behalf. And it isn’t the only one. hasattr() is literally an except AttributeError in disguise:

class T:
    @property
    def boom(self): raise AttributeError("I raised this myself")
    @property
    def other(self): raise ValueError("not an AttributeError")

print('hasattr(t, "boom")  ->', hasattr(T(), "boom"))
try:
    hasattr(T(), "other")
except ValueError as e:
    print('hasattr(t, "other") -> ValueError propagated:', e)
hasattr(t, "boom")  -> False
hasattr(t, "other") -> ValueError propagated: not an AttributeError

hasattr swallows AttributeError and reports False — and only AttributeError; a ValueError from the same property flies straight through. That’s a built-in doing exactly what this lesson preaches: catch precisely the one class you have a plan for.

The optional-dependency import is the same idea, and you’ll write it yourself:

try:
    import orjson as json_impl          # fast, may not be installed
except ImportError:
    import json as json_impl            # always there

So the real rule isn’t “never use exceptions for flow” — it’s “use exceptions for the exceptional, and follow the protocol when the protocol is exceptions.”

contextlib.suppress — the honest try/except/pass

When you genuinely want to ignore a specific failure, there’s a cleaner spelling:

from contextlib import suppress
import os

with suppress(FileNotFoundError):
    os.remove("/nope/not-here.txt")
print("suppress: survived")
suppress: survived
try/except X: pass with suppress(X):
Lines 4 2
Intent “handle it” — but the body is empty, so a reader wonders if it’s a bug ignore it” — unambiguous
Multiple classes except (A, B): pass suppress(A, B)
Rest of the block after the failure Would still need care ⚠️ Skippedwith exits at the raise
Can it hide a typo? Only if you catch too broadly Same — suppress(Exception) is just as bad

That fourth row is the one to internalise: suppress abandons the rest of the block when the exception fires, exactly like try would. So keep the block to one statement. And suppress(Exception) is every bit as wrong as except Exception: pass — the tool doesn’t make broad catching safe, it just makes narrow catching prettier.


ExceptionGroup and except*: when several things fail at once

Everything so far assumes one exception at a time. Sometimes that’s a lie: validating a form, you don’t want the first error, you want all six. Running twenty tasks concurrently, three can fail independently. Python 3.11 added ExceptionGroup for exactly this.

def validate_all(rec):
    errs = []
    if not rec.get("name"):
        errs.append(ValueError("name is required"))
    if rec.get("port", 0) > 65535:
        errs.append(ValueError("port out of range"))
    if not isinstance(rec.get("tags", []), list):
        errs.append(TypeError("tags must be a list"))
    if errs:
        raise ExceptionGroup("record is invalid", errs)
    return rec

try:
    validate_all({"port": 70000, "tags": "a,b"})
except* ValueError as eg:
    print("except* ValueError ->", [str(e) for e in eg.exceptions])
except* TypeError as eg:
    print("except* TypeError  ->", [str(e) for e in eg.exceptions])
except* ValueError -> ['name is required', 'port out of range']
except* TypeError  -> ['tags must be a list']

Look carefully at what happened: both handlers ran. That’s the difference. A normal except picks one winner and stops; except* filters the group — each clause pulls out the matching sub-exceptions, and anything unmatched keeps propagating as a smaller group. eg is always a group, so you iterate eg.exceptions.

Uncaught, a group prints as a tree:

  + Exception Group Traceback (most recent call last):
  |   File "/home/vinod/python-exc-lab/eg2.py", line 1, in <module>
  |     raise ExceptionGroup("record is invalid", [
  | ExceptionGroup: record is invalid (2 sub-exceptions)
  +-+---------------- 1 ----------------
    | ValueError: port out of range
    +---------------- 2 ----------------
    | TypeError: tags must be a list
    +------------------------------------
except X except* X
Matches One exception Sub-exceptions inside a group
Handlers that run The first that matches Every clause that matches something
as e binds The exception Always an ExceptionGroup — use e.exceptions
Unmatched parts Propagate as-is Propagate as a smaller group
Mixing ⚠️ Can’t mix except and except* in one try
ExceptionGroup wraps Exception subclasses; is an Exception catchable by except*
BaseExceptionGroup can wrap KeyboardInterrupt; is not an Exception which is why it lives outside Exception

You’ll meet groups mostly through asyncio.TaskGroup (3.11+), which collects every failing task into one group rather than showing you whichever lost the race. For everyday code you rarely raise them by hand — but validation is the one case where reaching for ExceptionGroup beats stopping at the first mistake. Version note: ExceptionGroup and except* are 3.11+; on 3.10 and earlier the except* syntax is a SyntaxError.


Hands-on lab

You’ll build a small config validator: raise the right built-ins, wrap them in a custom hierarchy, watch else/finally order, read both chaining tracebacks, re-raise properly, and reproduce the bare-except bug. All stdlib — no pip install, no venv needed. About 15 minutes.

Step 1 — Make a lab directory and check your Python.

mkdir ~/python-exc-lab && cd ~/python-exc-lab
python3 -V
# Python 3.12.3        # 3.11+ needed for except*/add_note. Windows: py -3 -V

What just happened: Every file below lives here. except* and add_note() need 3.11+; everything else works on any Python 3.

Step 2 — Raise the right built-in (parse_port.py).

# parse_port.py — raise the RIGHT built-in for each kind of wrong
def parse_port(raw):
    """Parse a port number. Raises TypeError or ValueError."""
    if not isinstance(raw, str):
        raise TypeError(f"port must be a str, got {type(raw).__name__}")
    port = int(raw)                      # raises ValueError on "eighty"
    if not (1 <= port <= 65535):
        raise ValueError(f"port {port} out of range 1-65535")
    return port

for raw in ["8080", "eighty", "70000", 8080, None]:
    try:
        print(f"{raw!r:10} -> {parse_port(raw)}")
    except (TypeError, ValueError) as e:
        print(f"{raw!r:10} -> {type(e).__name__}: {e}")
$ python3 parse_port.py
'8080'     -> 8080
'eighty'   -> ValueError: invalid literal for int() with base 10: 'eighty'
'70000'    -> ValueError: port 70000 out of range 1-65535
8080       -> TypeError: port must be a str, got int
None       -> TypeError: port must be a str, got NoneType

What just happened: Wrong typeTypeError; right type, wrong valueValueError. The 'eighty' line cost you no code at all — int() raised a better ValueError than you’d have written. One tuple except caught both classes.

Step 3 — Watch the clause order (order_demo.py).

# order_demo.py — else runs only on success; finally always runs
def probe(n):
    print(f"--- probe({n!r})")
    try:
        print("  try      : start")
        r = 10 / n
        print("  try      : ok ->", r)
    except ZeroDivisionError as e:
        print("  except   : caught", type(e).__name__)
    else:
        print("  else     : no exception ran")
    finally:
        print("  finally  : always")

probe(2)
probe(0)
$ python3 order_demo.py
--- probe(2)
  try      : start
  try      : ok -> 5.0
  else     : no exception ran
  finally  : always
--- probe(0)
  try      : start
  except   : caught ZeroDivisionError
  finally  : always

What just happened: else ran only for probe(2). finally ran for both. Note else comes before finally — success path first, cleanup last.

Step 4 — ⚠️ Watch finally eat an exception (swallow_return.py).

# swallow_return.py — a return in finally discards a live exception
def swallow_exc():
    try:
        raise ValueError("boom")
    finally:
        return "finally ate the exception"     # <-- never do this

def eval_order():
    x = "original"
    try:
        return x
    finally:
        x = "mutated"                          # too late
    
print("swallow_exc() ->", swallow_exc())
print("eval_order()  ->", eval_order())
$ python3 swallow_return.py
swallow_exc() -> finally ate the exception
eval_order()  -> original

What just happened: A ValueError was raised and vanished — no traceback, no handler, just a returned string. That’s an invisible bare except:. And eval_order() proved the return value is computed before finally runs, so rebinding x there changes nothing.

Step 5 — A custom exception hierarchy (errors.py).

# errors.py — one base class = one name your callers can catch
class AppError(Exception):
    """Base for every error this app raises on purpose."""

class ConfigError(AppError):
    """The config could not be understood."""

class FieldError(ConfigError):
    """A single field failed validation. Carries the context."""
    def __init__(self, field, value, reason):
        self.field = field
        self.value = value
        self.reason = reason
        super().__init__(f"{field}={value!r}: {reason}")   # <- sets .args and str()

class MissingFieldError(ConfigError):
    def __init__(self, field):
        self.field = field
        super().__init__(f"required field {field!r} is missing")

What just happened: Nothing yet — but you now have a surface. AppError is the one name a caller needs; FieldError carries structured data (.field, .value, .reason) so nobody has to parse your message.

Step 6 — Use it, and chain deliberately (config.py).

# config.py — translate low-level failures into YOUR error surface
from errors import AppError, ConfigError, FieldError, MissingFieldError

RAW = {"host": "10.0.0.7", "port": "eighty", "retries": "3"}

def get_field(cfg, name):
    try:
        return cfg[name]
    except KeyError:
        raise MissingFieldError(name) from None    # KeyError is an impl detail

def parse_port(cfg):
    raw = get_field(cfg, "port")
    try:
        port = int(raw)
    except ValueError as e:
        raise FieldError("port", raw, "must be an integer") from e   # keep the cause
    if not (1 <= port <= 65535):
        raise FieldError("port", port, "out of range 1-65535")
    return port

def load(cfg):
    return {"host": get_field(cfg, "host"), "port": parse_port(cfg)}

if __name__ == "__main__":
    for cfg in (RAW, {"host": "h", "port": "8080"}, {"port": "80"}):
        try:
            conf = load(cfg)
        except FieldError as e:
            print(f"FieldError  -> field={e.field} value={e.value!r} reason={e.reason}")
        except ConfigError as e:
            print(f"ConfigError -> {e}")
        except AppError as e:
            print(f"AppError     -> {e}")
        else:
            print(f"loaded      -> {conf}")
        finally:
            print("  ...validated one config")
$ python3 config.py
FieldError  -> field=port value='eighty' reason=must be an integer
  ...validated one config
loaded      -> {'host': 'h', 'port': 8080}
  ...validated one config
ConfigError -> required field 'host' is missing
  ...validated one config

What just happened: Three handlers, ordered most specific firstFieldError, then its parent ConfigError, then the root AppError. Flip them and the FieldError branch would be dead code. Note the two different chaining choices: from None for the KeyError (an implementation detail), from e for the ValueError (a real cause).

Step 7 — Read both traceback forms (show_chain.py).

# show_chain.py — let the chained exception escape, and READ the traceback
from config import RAW, parse_port
parse_port(RAW)          # port="eighty" -> FieldError from ValueError
$ python3 show_chain.py
Traceback (most recent call last):
  File "/home/vinod/python-exc-lab/config.py", line 15, in parse_port
    port = int(raw)
           ^^^^^^^^
ValueError: invalid literal for int() with base 10: 'eighty'

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/home/vinod/python-exc-lab/show_chain.py", line 3, in <module>
    parse_port(RAW)          # port="eighty" -> FieldError from ValueError
    ^^^^^^^^^^^^^^^
  File "/home/vinod/python-exc-lab/config.py", line 17, in parse_port
    raise FieldError("port", raw, "must be an integer") from e   # keep the cause
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
errors.FieldError: port='eighty': must be an integer

What just happened: “The above exception was the direct cause” — that’s from e talking. Both tracebacks are there: the ValueError that actually happened, and your FieldError that explains it. Note errors.FieldError is module-qualified. Now delete from e on line 17 and re-run: the sentence becomes “During handling of the above exception, another exception occurred” — same information, weaker claim.

Step 8 — The suppressed form (show_none.py).

# show_none.py — the SAME failure, but the cause is suppressed
from config import get_field
get_field({"port": "80"}, "host")     # MissingFieldError ... from None
$ python3 show_none.py
Traceback (most recent call last):
  File "/home/vinod/python-exc-lab/show_none.py", line 3, in <module>
    get_field({"port": "80"}, "host")     # MissingFieldError ... from None
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/vinod/python-exc-lab/config.py", line 10, in get_field
    raise MissingFieldError(name) from None
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
errors.MissingFieldError: required field 'host' is missing

What just happened: One traceback, no KeyError in sight. from None hid it — correct here, because “I used a dict” is not your caller’s business. Try removing from None to see the KeyError reappear, and ask yourself whether it helped.

Step 9 — Re-raise without lying (reraise.py).

# reraise.py — log it, then let it fly: bare `raise` keeps the origin
import logging
logging.basicConfig(level=logging.ERROR, format="%(levelname)s %(message)s")

def parse(raw):
    return int(raw)                    # line 6 - the REAL origin

def handler(raw):
    try:
        return parse(raw)
    except ValueError:
        logging.error("bad input %r, re-raising", raw)
        raise                          # bare: no new frame, no lie

handler("eighty")
$ python3 reraise.py
ERROR bad input 'eighty', re-raising
Traceback (most recent call last):
  File "/home/vinod/python-exc-lab/reraise.py", line 15, in <module>
    handler("eighty")
  File "/home/vinod/python-exc-lab/reraise.py", line 10, in handler
    return parse(raw)
           ^^^^^^^^^^
  File "/home/vinod/python-exc-lab/reraise.py", line 6, in parse
    return int(raw)                    # line 6 - the REAL origin
           ^^^^^^^^
ValueError: invalid literal for int() with base 10: 'eighty'

What just happened: You logged and kept the exception, with a traceback ending exactly at line 6 where it really broke — three frames, all of them true. Change raise to raise e (and except ValueError: to except ValueError as e:) and re-run: a fourth, bogus line 13, in handler / raise e frame appears, pointing at your logging line instead of the bug.

Step 10 — Reproduce the bare-except bug, then fix it (swallow.py).

# swallow.py — a bare except turns a typo into a wrong answer
class Row:
    def __init__(self, amount):
        self.amount = amount

def total_bad(rows):
    try:
        return sum(r.amuont for r in rows)     # typo: amuont
    except:                                    # <- catches the AttributeError too
        return 0

def total_good(rows):
    try:
        return sum(r.amount for r in rows)
    except TypeError:                          # only what we planned for
        return 0

rows = [Row(10), Row(32)]
print("total_bad  ->", total_bad(rows), "  <- silently WRONG, no traceback")
print("total_good ->", total_good(rows))
$ python3 swallow.py
total_bad  -> 0   <- silently WRONG, no traceback
total_good -> 42

What just happened: total_bad returned 0 for rows totalling 42. No error, no traceback, no clue — the bare except: caught the AttributeError from the typo and reported it as “no rows.” This is the bug from the top of the lesson, in nine lines.

Step 11 — Delete the bare except and let Python talk (swallow_fixed.py).

# swallow_fixed.py — delete the bare except and Python tells you the truth
class Row:
    def __init__(self, amount):
        self.amount = amount

def total(rows):
    return sum(r.amuont for r in rows)         # the same typo, now unguarded

print("total ->", total([Row(10), Row(32)]))
$ python3 swallow_fixed.py
Traceback (most recent call last):
  File "/home/vinod/python-exc-lab/swallow_fixed.py", line 9, in <module>
    print("total ->", total([Row(10), Row(32)]))
                      ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/vinod/python-exc-lab/swallow_fixed.py", line 7, in total
    return sum(r.amuont for r in rows)         # the same typo, now unguarded
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/vinod/python-exc-lab/swallow_fixed.py", line 7, in <genexpr>
    return sum(r.amuont for r in rows)         # the same typo, now unguarded
               ^^^^^^^^
AttributeError: 'Row' object has no attribute 'amuont'. Did you mean: 'amount'?

What just happened: The same typo, now loudly diagnosed — Python even suggested amount (3.10+), and the <genexpr> frame points inside the generator expression. Removing the handler didn’t create a bug; it revealed one that was always there. That’s the lesson: a crash is a gift, and except: is how you refuse it.

Step 12 — Clean up.

cd ~ && rm -rf ~/python-exc-lab       # Windows: rmdir /s /q %USERPROFILE%\python-exc-lab

⚠️ rm -rf has no undo and no recycle bin. Read the path before you press Enter.


Common mistakes and troubleshooting

Symptom / traceback Cause Fix
Ctrl-C doesn’t stop the script A bare except: / except BaseException: inside a loop is eating KeyboardInterrupt except Exception: — it deliberately excludes KeyboardInterrupt and SystemExit
sys.exit(1) doesn’t exit Same — SystemExit is not an Exception, and you caught BaseException except Exception:, and re-raise if you can’t handle it
A function returns 0/None/[] and no error appears except Exception: pass or bare except: swallowed a typo (AttributeError) Catch the specific class. If you must be broad, log.exception(...) then raise
An except branch never runs A superclass is listed above it — except OSError: before except FileNotFoundError: Subclass before superclass. except Exception: goes last
A function returns a value even though it raised ⚠️ A return inside finally discarded the exception Never return/break/continue in finally. Lint: ruff B012
The finally return overrides the try return Same mechanism — finally exits last, so its return wins Compute the value in try/else; keep finally to cleanup
Traceback points at your logging code, not the bug raise e inserted a frame at the handler Bare raise
Traceback shows only the last error, origin gone raise X(...) from None, or raise ValueError(str(e)) built a new object raise X(...) from e
Two unrelated errors joined by “During handling…” raise e (a stored exception) inside a different except block Don’t re-raise across contexts; if you must, from None or restructure
TypeError: exceptions must derive from BaseException class AppError: — you forgot to inherit class AppError(Exception):
TypeError: AppError() takes no arguments Same root cause: a plain class, object.__init__ rejects your message class AppError(Exception):
Traceback last line reads MyError: ('a', 'b', 'c') Custom __init__ never called super().__init__(msg); .args came from __new__ super().__init__(f"...") with one readable string
RuntimeError: generator raised StopIteration ⚠️ A StopIteration escaped a generator body (PEP 479) Catch it: try: x = next(it) except StopIteration: return
except OSError: still crashes on a bad file UnicodeDecodeError is a ValueError, not an OSError Catch it separately, or except (OSError, UnicodeDecodeError):
NameError: name 'e' is not defined after the block except X as e: deletes e at the end of the block Assign it inside: err = e
SyntaxError on except ValueError, TypeError: Python 2 syntax except (ValueError, TypeError): — a tuple
SyntaxError on except* except* needs Python 3.11+ Upgrade, or restructure without groups
Catching TypeError masks a real bug except TypeError: around a big block also catches “argument of wrong type” from your code Shrink the try; validate explicitly and raise your own

Four of these deserve more than a row.

1. StopIteration inside a generator becomes RuntimeError (PEP 479). This is the nastiest one on the list because the fix isn’t where the error points. Call next() inside a generator body, let it exhaust, and:

def first_word(lines):
    it = iter(lines)
    while True:
        line = next(it)          # raises StopIteration when exhausted
        if line.strip():
            yield line.split()[0]

print(list(first_word(["alpha beta", "gamma"])))
Traceback (most recent call last):
  File "/home/vinod/python-exc-lab/pep479.py", line 4, in first_word
    line = next(it)          # raises StopIteration when exhausted
           ^^^^^^^^
StopIteration

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/home/vinod/python-exc-lab/pep479.py", line 8, in <module>
    print(list(first_word(["alpha beta", "gamma"])))
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
RuntimeError: generator raised StopIteration

Before PEP 479 (Python 3.7), that bare StopIteration would have silently ended the generator — your for loop would stop early and quietly give you fewer results, with no error at all. Python now converts it to a RuntimeError precisely so this can’t hide. The fix is to handle exhaustion yourself: try: line = next(it) / except StopIteration: return. (Or just write for line in it:, which does it for you.)

2. except Exception: pass is untestable. Every other handler makes a claim you can check. except FileNotFoundError: pass says “a missing file is fine here” — a reviewer can agree or disagree, and you can write a test for it. except Exception: pass makes no claim at all; it does the same thing for a missing file, a typo, and a corrupted install. There is no test that proves it correct, because there’s no behaviour to assert. If you’re tempted, ask: which failures am I actually expecting? Name those. If the honest answer is “I don’t know, it kept crashing,” the crash was information and you just deleted it.

3. Catching too broadly masks bugs in your own code. except TypeError: looks specific — it’s one class. But TypeError is also what you get from calling a function with the wrong arguments, and if your try block spans ten lines, a genuine bug in line 7 (total(rows, tax) when total takes one argument) is indistinguishable from the input problem you meant to handle. Specificity isn’t only about the class; it’s about the size of the block. A one-line try with except TypeError: is precise. A twenty-line try with the same handler is a net.

4. Losing the traceback is usually a logging bug, not a raise bug. The most common way people destroy debugging information isn’t raise e — it’s this:

except Exception as e:
    log.error(f"failed: {e}")      # ❌ message only. No traceback, no location.

You get failed: invalid literal for int() with base 10: 'eighty' in the log and nothing else — no file, no line, no call path. Use log.exception("failed") inside an except block (or log.error("failed", exc_info=True)): identical message, plus the full traceback. It’s the single highest-value one-word change in most codebases.


Cheat-sheet

Statement What it does
try: Wrap the smallest risky operation
except X: Catch X and its subclasses. Subclass first, Exception last
except (X, Y): Catch either — a tuple; parens required
except X as e: Bind the object. ⚠️ e is deleted after the block
else: Runs only if no exception. Put the success path here
finally: Always runs — success, exception, return. Cleanup only
raise X("msg") Raise a new instance. The default
raise Re-raise the current exception, traceback intact. ✅ In except
raise X from e Set __cause__ → “The above exception was the direct cause”
raise X from None Suppress the context → only X shown. ⚠️ Deletes evidence
with suppress(X): contextlib — ignore X. Skips the rest of the block
except* X: 3.11+ — filter an ExceptionGroup; every matching clause runs
raise ExceptionGroup("m", [e1, e2]) 3.11+ — report several failures at once
e.add_note("...") 3.11+ — attach context; prints under the exception line
Hierarchy / object What to know
BaseException The root. ⚠️ Never catch it
KeyboardInterrupt Ctrl-C — outside Exception on purpose
SystemExit sys.exit() — outside Exception on purpose
GeneratorExit Generator cleanup — outside Exception on purpose
Exception The one you catch. Every ordinary error
LookupError KeyError, IndexError
ArithmeticError ZeroDivisionError, OverflowError
OSError FileNotFoundError, PermissionError, TimeoutError. Has .errno
ValueError UnicodeErrorUnicodeDecodeError (⚠️ not an OSError)
RuntimeError NotImplementedError, RecursionError
e.args Constructor args as a tuple. str(e) = args[0] if len==1
e.__traceback__ The frame chain. None until raised
e.__context__ Auto-set: what you were handling. “During handling…”
e.__cause__ Set by from e. “The above exception was the direct cause…”
Which to raise When
ValueError Right type, wrong valueint("eighty"), port 70000
TypeError Wrong typelen(5), None where a str is needed
KeyError / IndexError Missing mapping key / out-of-range sequence index
AttributeError No such attribute on the object
FileNotFoundError Path doesn’t exist (an OSError)
StopIteration Iterator exhausted. ⚠️ Never let it escape a generator
RuntimeError Nothing else fits — the misc bin
NotImplementedError Abstract method a subclass must override
class AppError(Exception) ✅ One base class per app, so callers catch one name
class MyErr(MyBase, ValueError) Both: old except ValueError: works, new except MyBase: is precise
except: Never. Catches Ctrl-C, sys.exit(), your typos
except Exception: pass Never. Untestable, silent, hides real failures
return in finally Never. Silently discards a live exception
log.exception("msg") Inside except — message plus traceback

Interview and exam questions

Q: Why do you catch Exception rather than BaseException? A: Because BaseException has four other children that are not program errors: KeyboardInterrupt (Ctrl-C), SystemExit (sys.exit()), GeneratorExit, and BaseExceptionGroup. They sit outside Exception deliberately so that a broad except Exception: can’t swallow them. Catch BaseException — or its equivalent, bare except: — and your program ignores Ctrl-C and neutralises sys.exit(). Exception is the line drawn exactly around “something went wrong in my program.”

Q: What’s the difference between try/except and try/except/else, and why bother with else? A: else runs only if the try block raised nothing, and it isn’t protected by the except. That lets you shrink the try to just the risky call and put the success path in else. If you leave the success path inside try, your except ValueError: is a net stretched under every statement in the block — so an unrelated ValueError from three libraries deep gets caught and mislabelled as your error. Small try, success in else.

Q: When does finally run — and what does this print?

def f():
    try:
        return "try"
    finally:
        return "finally"

A: finally runs always — on success, on a handled exception, on an unhandled one propagating through, and on return/break/continue. So this prints finally: the try’s return computes its value and starts to exit, finally runs on the way out, and its return replaces the pending one. The real gotcha is the same mechanism with an exception — had the try raised, that exception would be silently discarded and "finally" returned as though nothing failed. This is why linters (ruff B012, pylint lost-exception) flag any return in a finally, and why the rule is: finally is for cleanup only.

Q: Explain the difference between __context__ and __cause__. A: Both chain two exceptions; they differ in what they claim. __context__ is set automatically whenever you raise inside an except block, and prints “During handling of the above exception, another exception occurred” — a statement that these happened one after the other, which may mean the second is a bug in your handler. __cause__ is set explicitly by raise X from e, and prints “The above exception was the direct cause of the following exception” — you’re asserting the first explains the second. raise X from None sets __suppress_context__ and prints neither.

Q: What’s wrong with raise e to re-raise, and what should you write? A: Write a bare raise. raise e doesn’t erase the traceback — the frames are attached to the object — but it inserts an extra frame pointing at your handler, so the traceback shows your logging line as part of the failure path. Do it at four layers and you get four fake frames. It’s worse when re-raising a stored exception from a different except block, because Python then chains it to whatever unrelated error is currently being handled and prints a misleading “During handling…”. Bare raise re-raises the live exception, unchanged.

Q: Why is this handler ordering a bug?

except OSError: ...
except FileNotFoundError: ...

A: FileNotFoundError is a subclass of OSError, and Python tests handlers top-down, stopping at the first isinstance match. So OSError catches everything first and the FileNotFoundError branch is unreachable dead code — with no warning. Always order subclass before superclass, which means except Exception: must come last if it appears at all.

Q: When should you write a custom exception instead of raising ValueError? A: When someone will catch it specifically. Concretely: (1) you’re a library and callers must distinguish your errors from the stdlib’s; (2) the error carries structured data (.field, .retry_after) that callers shouldn’t have to regex out of a message; (3) callers need to make a policy decision on class — retry a TransientError, fail a PermanentError; (4) you’re crossing a boundary and the low-level error would leak an implementation detail. For a helper nobody catches, a built-in is better. And give your app one base class (AppError) so callers can catch your whole surface with one name.

Q (coding): Write a parse_config that raises ValueError for a bad port, TypeError for a non-string, and wraps everything in a ConfigError for callers — without losing the original. A:

class ConfigError(Exception):
    """Base for config problems - callers catch this one name."""

def parse_port(raw):
    if not isinstance(raw, str):
        raise TypeError(f"port must be a str, got {type(raw).__name__}")
    port = int(raw)                                    # ValueError, and it's a good one
    if not (1 <= port <= 65535):
        raise ValueError(f"port {port} out of range 1-65535")
    return port

def parse_config(cfg):
    try:
        return {"port": parse_port(cfg["port"])}
    except KeyError as e:
        raise ConfigError("missing 'port'") from None  # dict is an impl detail
    except (ValueError, TypeError) as e:
        raise ConfigError(f"bad port: {e}") from e     # keep the real cause

The points tested: the right built-in per failure kind, letting int()'s excellent ValueError through rather than reinventing it, a base class so callers catch one name, from e where the cause explains the result, and from None where the original is noise.

Q: Is using exceptions for control flow bad? A: Overstated. Python’s iterator protocol is exception-based: every for loop catches StopIteration, and that’s idiomatic, not a smell. EOFError ending an input loop is the same. The real rule is that exceptions should describe the exceptional — and that where the protocol is exceptions, you follow the protocol. What’s genuinely bad is inventing exception-driven flow where an if reads better, or raising inside a hot loop where failure is the common case, since raising costs microseconds while try costs nothing when nothing raises.

Q: Why does this raise RuntimeError instead of ending cleanly?

def gen(lines):
    it = iter(lines)
    while True:
        yield next(it)

A: PEP 479. When next(it) exhausts, it raises StopIteration inside the generator body. Since Python 3.7, a StopIteration escaping a generator is converted into RuntimeError: generator raised StopIteration. Before that change it silently terminated the generator — so a bug like this made your loop return fewer results with no error at all, which is far worse. Fix by handling exhaustion: try: yield next(it) / except StopIteration: return — or just yield from it.

Q: What’s the difference between except X and except* X? A: except matches a single exception and only the first matching clause runs. except* (3.11+) works on an ExceptionGroup: it filters the group, every matching clause runs, the bound variable is always a group (use .exceptions), and anything unmatched keeps propagating as a smaller group. You can’t mix the two forms in one try. Groups matter for asyncio.TaskGroup, where several concurrent tasks can fail independently, and for validation where you want all the errors, not the first.

Q: A colleague writes except Exception as e: log.error(f"failed: {e}"). What’s wrong? A: The log line has the message but no traceback — no file, no line number, no call path. You’ll know a ValueError happened somewhere in a thousand lines. Use log.exception("failed") inside the handler (or log.error("failed", exc_info=True)), which logs the same message plus the full traceback. Separately, unless this handler is a top-level boundary that genuinely continues, it should raise after logging — otherwise it’s except Exception: pass with extra steps.


Key takeaways

pythonexceptionstry-excepterror-handlingcustom-exceptionsexception-hierarchyraiseexception-chainingeafptracebacksexceptiongroupcontextliboopintermediate
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments