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:
- A raise creates an object.
ValueError("bad port")is an instance, with a class, an.argstuple, and — once raised — a__traceback__. It is data. You can store it, inspect it, add notes to it, re-raise it later. - The object propagates by unwinding. Python pops the current frame, asks “does an
excepthere match this class?”, and if not pops the next one, and the next. The traceback you see printed is that unwind path, written down. - A handler is a class test, not a string test.
except ValueError:meansisinstance(exc, ValueError). That single fact explains ordering rules, why subclassing matters, and why a per-app base class is such a powerful idea.
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 |
UnicodeError → UnicodeDecodeError/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 str — int() 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 line — raise 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.
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: message — what 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:
- Attributes are for the caller:
e.fieldlets them build a form error without parsing your message. super().__init__(msg)is for the human: it sets.argsand thereforestr(e), which is what lands in the traceback.
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: EAFP — Easier 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 | ⚠️ Skipped — with 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 type → TypeError; right type, wrong value → ValueError. 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 first — FieldError, 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 -rfhas 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 |
→ UnicodeError → UnicodeDecodeError (⚠️ 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 value — int("eighty"), port 70000 |
TypeError |
Wrong type — len(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
- The class you raise is your API. Callers dispatch on class, not on your message string.
TypeError= “I can’t work with this kind of thing at all”;ValueError= “I can work with this kind, just not this one.” Reuse a built-in’s exception when it’s already right —int()raises a betterValueErrorthan you’d write. - Catch
Exception, neverBaseException.KeyboardInterrupt,SystemExitandGeneratorExitsit outsideExceptionon purpose. Bareexcept:isexcept BaseException:— it makes your program ignore Ctrl-C and swallow your own typos. - An exception is an object that travels — and the traceback is its route. It has a class,
.args,.__traceback__,__context__and__cause__, and it propagates by unwinding the stack frame by frame, testingisinstanceat each. That’s why subclass must come before superclass, or the specific handler is unreachable dead code. It’s also why you read a traceback bottom-up: the last line is the error and the class you’d catch, the frame above it is where it broke, and the frames above that are how you got there. “Most recent call last” is the whole key — and the^^^^carets (3.11+) point at the exact sub-expression. - Small
try, success inelse, cleanup infinally. A bigtryblock turns yourexceptinto a net that misclassifies unrelated failures as your own. ⚠️ Neverreturn/break/continuefromfinally— it overrides the return and silently discards a live exception. - Chain deliberately. Raising inside an
exceptauto-links__context__(“During handling…”).raise X from esets__cause__(“The above exception was the direct cause…”) and is what you want when translating a low-level error at a boundary.from Nonedeletes evidence — use it only for genuine implementation details, never to shorten a traceback. - Re-raise with a bare
raise.raise einserts a bogus frame pointing at your handler;raise ValueError(str(e))throws away the class,.errnoand.args. And the most common real loss islog.error(f"{e}")— uselog.exception(...)and keep the traceback. - Give your app one base class.
class AppError(Exception)with everything inheriting from it means callers writeexcept AppError:once and it keeps working as you add error types. Put structured data in attributes, and always callsuper().__init__(msg)— forget it and your traceback prints a raw tuple. - Prefer EAFP. Your guard is only an approximation of the operation (
isdigit()rejects" 8080 "thatint()accepts), and a check-then-use has a race. Try it and handle the failure. Usecontextlib.suppress(X)when you genuinely mean “ignore this specific error” — neversuppress(Exception).