Intuition What this page is
The parent note taught you the rules of raise. Here we walk every kind of situation where raise behaves differently — every "quadrant" of the exception world — and work each one fully. Guess the answer before you read the steps. If you never hit a case here, it doesn't exist.
We treat "cases" the way a geometry deep-dive treats quadrants: each cell of the matrix below is a distinct region of behaviour, and we make sure a worked example lands in every cell.
Think of "what you write after raise" and "what state you're in" as two axes. Every combination lands somewhere:
Cell
Case class
What triggers it
Danger if you get it wrong
A
raise Class (bare class)
You give the type , no message
Empty .args — nothing prints
B
raise Class("msg") (instance)
You give type + message
This is the normal, safe path
C
Custom exception, pass body
Named category, no data
Handler knows what , not how much
D
Custom exception carrying data
Handler needs the numbers
Forgetting super().__init__ → blank message
E
Bare raise (re-raise)
Inside except, pass it on
Losing the original traceback
F
raise B from A (chaining)
Translate low-level → domain
Hiding the real cause
G
Degenerate / zero numeric input
amount = 0 or negative
Continuing with corrupt state
H1
The guard check
Refuse invalid input up front
Corrupt state slips through
H2
The "make ≠ raise" trap
return Exception(...)
Caller gets an object , not a crash
I
Real-world word problem
Bank withdrawal
Silent wrong balance
J
Exam twist
Which except catches it?
Wrong handler runs / none runs
The 8 examples below cover all eleven cells (some examples hit two).
We catch each raise so we can inspect the resulting exception object, which we bind to the name e (that is what except ValueError as e does — it hands you the live exception under the name e):
try :
raise ValueError # case A: bare class
except ValueError as e:
print (e.args, "|" , str (e)) # what does e hold?
try :
raise ValueError ( "too small" ) # case B: instance
except ValueError as e:
print (e.args, "|" , str (e))
For each try, what does e.args hold, and what does print(str(e)) show?
Forecast: guess .args for each before reading.
Case A gives a class . Why this step? After raise, Python checks: instance or class? A bare class has no (), so Python calls it for you — ValueError() with no arguments . The except ... as e then binds that object to e. Result: e.args == () (empty tuple), and str(e) == "".
Case B gives an instance . Why this step? You already wrote ("too small"), so the object is built with that one argument before e is bound. Result: e.args == ("too small",) and str(e) == "too small".
Why two separate try blocks? raise interrupts flow immediately, so we isolate each case in its own block; the second try runs only after the first is fully handled. We reason about them independently.
Verify: ValueError().args is (); ValueError("too small").args is ("too small",). Empty .args is exactly the "Cell A danger": nothing prints. See Exception Hierarchy & BaseException for why both are still valid exceptions.
class InsufficientFundsError ( Exception ):
pass
Is InsufficientFundsError a subclass of Exception? If we raise InsufficientFundsError("nope"), what is str(e)?
Forecast: does an empty body still get a working message?
The pass body inherits everything. Why this step? A class with only pass is not empty of behaviour — it takes all machinery (including __init__ and .args handling) from its parent Exception. See Classes and __init__ .
So ("nope") still populates .args. Why this step? The inherited Exception.__init__ stores positional args, so str(e) == "nope". You get a working message for free — no __init__ needed when you carry no extra data .
issubclass check. Why this step? Catchability depends on the class tree: except InsufficientFundsError works, and so does the broader except Exception, precisely because the subclass relation holds.
Verify: issubclass(InsufficientFundsError, Exception) is True; str(InsufficientFundsError("nope")) is "nope".
class InsufficientFundsError ( Exception ):
def __init__ (self, balance, requested):
self .balance = balance
self .requested = requested
self .shortfall = requested - balance
super (). __init__ ( f "Tried { requested } , only { balance } available." )
After raise InsufficientFundsError(balance=50, requested=80), what are e.shortfall and str(e)?
Forecast: compute the shortfall in your head first.
Set the attributes. Why this step? The handler will want the numbers , not just the name. self.shortfall = 80 - 50 = 30.
Call super().__init__(...). Why this step? This is the load-bearing line. It hands your formatted string to Exception's constructor, which stores it in .args. Skip it and str(e) is blank — the Cell D disaster.
Handler reads structured data. Why this step? Because e.shortfall is a plain number, the handler can branch on it — for example if e.shortfall < 100: retry() — decision logic driven by real values, not just by the exception's name .
Verify: e.shortfall == 30; str(e) == "Tried 80, only 50 available."; e.args == ("Tried 80, only 50 available.",).
class BadError ( Exception ):
def __init__ (self, code):
self .code = code # note: NO super().__init__ call
After raise BadError(404), what does print(e) show, and what is e.args?
Forecast: will print(e) show 404? Guess.
We overrode __init__ and never called super. Why this step? Overriding replaces the parent's constructor entirely. Since we never forwarded anything, the message channel is broken — str(e) will not show 404.
str(e) returns the empty string, not our data. Why this step? When .args is empty, Exception.__str__ simply returns "" — an empty string. It does not print a repr and it does not magically find self.code. Nothing helpful prints as the message.
But e.code still works. Why this step? The attribute we set by hand survives; only the message channel is broken. That's the subtle trap — it half works.
Verify: str(e) == "" (empty message) while e.code == 404. Contrast with Example 3, where the super() call made str(e) meaningful.
logged = [] # define the log list up front
def process ():
try :
raise IOError ( "disk problem" )
except IOError as e:
logged.append( str (e)) # pretend this is logging
raise # bare raise
After calling process(), (a) what is in logged, and (b) what exception type escapes process?
Forecast: does the bare raise throw a new error or the same one?
Enter the except, log the message. Why this step? We "peek" at the problem to record it, following the log-and-re-raise pattern from Logging in Python . logged (the list we created before process) gets "disk problem".
Bare raise re-throws the current exception. Why this step? Inside an except, a lone raise means "the one I'm handling, keep going." It preserves the original traceback — critical for debugging where it started.
It escapes because there's no outer try. Why this step? Following the propagation rule from the parent note, no matching handler upward ⇒ it leaves process as an IOError.
Verify: logged == ["disk problem"]; the escaping exception is an IOError (same type, same message).
class ConfigError ( Exception ):
pass
def parse_port (text):
try :
return int (text)
except ValueError as e:
raise ConfigError( "bad port number" ) from e
Call parse_port("http"). (a) What type escapes? (b) What is escaped.__cause__'s type?
Forecast: what error does int("http") produce first?
int("http") fails with ValueError. Why this step? "http" has no integer meaning — the low-level, generic error.
We translate to a domain error. Why this step? Callers care about "bad config" , not "bad int" . raise ConfigError(...) gives them a name they can catch specifically.
from e links the two. Why this step? It sets ConfigError.__cause__ = the ValueError, so the traceback prints "The above exception was the direct cause…". You upgrade the message without hiding the root.
Verify: the escaping type is ConfigError; escaped.__cause__ is a ValueError. See Try-Except-Finally for how the outer catch would see ConfigError.
A bank method:
class InsufficientFundsError ( Exception ):
def __init__ (self, balance, requested):
self .shortfall = requested - balance
super (). __init__ ( f "short by { requested - balance } " )
def withdraw (balance, amount):
if amount <= 0 :
raise ValueError ( "amount must be positive" ) # H1 guard: G zero/negative
if amount > balance:
raise InsufficientFundsError(balance, amount) # cell I: word problem
return balance - amount
Evaluate the outcome for four inputs:
withdraw(100, 30), withdraw(100, 0), withdraw(100, -5), withdraw(50, 80).
Forecast: which two raise the same type? Which returns a number?
withdraw(100, 30) → valid. Why this step? 30 > 0 and 30 ≤ 100, so no guard fires; we return the real result 70. This is the "return an answer" branch.
withdraw(100, 0) → ValueError (Cell G, zero). Why this step? 0 <= 0 is True. Zero is a degenerate amount — withdrawing nothing is meaningless, so we refuse loudly instead of returning 100 unchanged.
withdraw(100, -5) → ValueError (Cell G, negative). Why this step? Same guard. Negative amounts would add money if we naively did balance - amount — a corrupt state. Both zero and negative funnel to the same narrow H1 guard .
withdraw(50, 80) → InsufficientFundsError, shortfall == 30 (Cell I). Why this step? 80 > 50, so we raise the domain exception carrying the gap. shortfall = 80 - 50 = 30. This lets the caller offer "top up 30?" instead of just crashing.
Verify: withdraw(100, 30) == 70; input 0 and input -5 both raise ValueError; withdraw(50, 80) raises InsufficientFundsError with shortfall == 30.
Intuition Reading the decision-tree figure below
The figure draws withdraw as a flowchart. Start at the top cyan box — the call enters. The first white diamond is the amount <= 0 ? guard (cell H1): follow the amber "True" arrow left and you land in the amber raise ValueError box — this is where both withdraw(100, 0) and withdraw(100, -5) end up. Follow the white "False" arrow down to the second white diamond amount > balance ?: its amber "True" arrow (bottom right) reaches raise InsufficientFundsError with shortfall = 30 (that's withdraw(50, 80)), while its cyan "False" arrow reaches the cyan return balance - amount box — the only path that gives a real number, withdraw(100, 30) → 70. The two amber boxes are the loud failures ; the single cyan box is the answer .
def check (x):
if x < 0 :
return ValueError ( "negative!" ) # <-- return, not raise
return x
r = check( - 7 )
What type is r? Does the program crash?
Forecast: is r an error being thrown, or just… an object sitting there?
return ValueError(...) builds an object. Why this step? Creating an exception is just calling a class — you get an instance . Nothing is thrown; flow is normal.
The caller receives it like any value. Why this step? check(-7) returns , so r is bound to a ValueError instance. No crash, no traceback, program keeps running with a fake "answer."
The fix. Why this step? Replace return with raise ValueError("negative!"). Make ≠ raise — you built the exception but never threw it.
Verify: isinstance(check(-7), ValueError) is True and it does not raise. That silent object is exactly the bug this cell warns about.
R.A.I.S.E. (from the parent note — worth pinning here)
R eturn for answers · A bort with raise · I nherit from Exception · S uper().init for the message · E xcept the narrowest type. This example is the R vs A distinction: returning an exception is not aborting with it.
Given the hierarchy InsufficientFundsError(Exception), this handler runs:
class InsufficientFundsError ( Exception ):
pass
try :
raise InsufficientFundsError( 50 , 80 )
except ValueError :
result = "value"
except Exception :
result = "exception"
except InsufficientFundsError:
result = "funds"
What is result? (The order of the except clauses is deliberate.)
Forecast: it's tempting to say "funds" because that's the exact type. Is it?
Python tries except clauses top-to-bottom. Why this step? Matching is first match wins , based on isinstance, not on "most specific."
ValueError? No. Why this step? InsufficientFundsError is not a subclass of ValueError, so this clause is skipped.
Exception? Yes. Why this step? Every custom exception is an Exception subclass, so isinstance(e, Exception) is True. This clause fires and sets result = "exception".
The InsufficientFundsError clause never runs. Why this step? A broad handler placed above a narrow one shadows it — the classic exam trap. Fix: always list specific handlers before general ones (see Try-Except-Finally ).
Verify: result == "exception" (not "funds"), and issubclass(InsufficientFundsError, ValueError) is False.
Recall Did we hit every cell A–J?
A,B → Ex 1 · C → Ex 2 · D → Ex 3 & 4 · E → Ex 5 · F → Ex 6 · G,H1,I → Ex 7 · H2 → Ex 8 · J → Ex 9. Every cell of the matrix has a worked example.
Recall Zero and negative both funnel where in the withdrawal example?
To the same ValueError guard amount <= 0 (cell H1) — one narrow check catches both degenerate cases.
Recall Why did Example 9 print
"exception" and not "funds"?
except clauses match first-to-last by isinstance; the broad except Exception sits above the specific one and shadows it.
Mnemonic Order your handlers
Narrow above broad. A wide net placed first catches everything, leaving the specific nets dry.