Exceptions & Errors — Keeping the App Calm When Things Go Wrong
Our Bill Splitter runs on live, half-typed input: someone clears the people field to 0, pastes "12%" into a number box, or taps a menu item that isn't there. Any one of those can make Python raise an exception and crash the whole app — so this lesson is about catching the ones we expect, letting real bugs surface, and deciding when to guard versus when to shout. It builds straight toward your safe_split(total, people).
Every box is live — edit and ▶ Run.
Part 1 — What "raises" means: the four errors you'll meet most
When Python hits an operation it genuinely cannot perform, it raises an exception — an object with a name (the type) and a message. Unhandled, the exception unwinds the program and prints a traceback. The name tells you what went wrong, so learn to recognise the common ones. Here are the four that show up constantly in a bill-splitting app:
100 / 0 # 💥 ZeroDivisionError: division by zero
int("four") # 💥 ValueError: invalid literal for int() with base 10
[10, 20][5] # 💥 IndexError: list index out of range
{"food": 84}["tip"] # 💥 KeyError: 'tip'Instead of crashing, let's catch each one and just print its type name so we can compare them side by side:
Note:
type(e).__name__gives you the exception's class name as a string — handy for logging. Each of these is a subclass ofException, which is why one broadexcept Exceptioncaught them all here.
Try it: Add a fifth attempt —
("bad type", lambda: "3" + 5)— and rerun. What name does mixing astrand anintraise?
Part 2 — try / except: catch it and carry on
Wrap the risky line in try:. If it raises, Python skips the rest of the try block and runs the matching except: instead of crashing. This is the exact shape behind safe_split — someone empties the people box, people becomes 0, and dividing would blow up:
The program keeps running and shows a calm 0.0 instead of a stack trace. That's the whole point: an expected, recoverable problem should not end the app.
Try it: Change
peopleto4. Thetryblock succeeds, theexceptis skipped entirely, andsharebecomes25.0.
Part 3 — Read the message with except ... as e
The exception object carries a human-readable message. Capture it with as e and you can log it, show it, or inspect it. This is gold when parsing user input, because the message tells you exactly what Python choked on:
Note: Printing an exception prints its message, not its type. If you want the type too, use
f"{type(e).__name__}: {e}".
Try it: Swap
"12%"for"12.5"— it parses fine and theexceptnever fires (nothing prints). Then try"$8"to see the message change.
Part 4 — Catch a SPECIFIC exception, not a broad one
Name the exact exception you expect. Catching ValueError handles bad text while letting unexpected failures still crash loudly — which is what you want, because a crash you can see beats a bug you can't:
Now watch what a lazy broad except Exception: does. Here there's a typo — subtotl instead of subtotal — which raises NameError. A broad catch swallows it and returns a wrong-but-quiet answer, so the bug ships:
The tip silently vanished and nobody noticed. A specific except ValueError: would have let that NameError crash the app during testing, where you'd fix it.
Watch out: A bare
except:(no type at all) is even worse — it also catchesKeyboardInterruptandSystemExit, so you can't even Ctrl-C out. Never write a bareexcept:. Catch the narrowest type that makes sense.
Part 5 — The full shape: try / except / else / finally
Two optional clauses complete the picture. else: runs only when the try succeeded (no exception) — the place for code that depends on the risky step working. finally: runs no matter what — success, handled error, even an early return — so it's for cleanup that must always happen:
Notice finally printed after else and before the function actually returned — Python guarantees it runs on the way out.
That guarantee is stronger than most people expect. finally runs even when the
try block already decided to return:
def f():
try:
return "from try"
finally:
print("cleanup, then ", end="")
print(f())Step through it if that ordering feels wrong — watching the frame return makes it click:
Try it: Call
read_amount("oops")instead. Now theexceptbranch runs, thenfinallystill fires — the order becomesnot a numberthen(input handled).
Note: Why
elseinstead of putting that code in thetry? So aValueErrorfrom later code doesn't get mis-caught by theexcept ValueError:meant forfloat(). Keeptryblocks as small as the risky operation.
Part 6 — Raising your own with raise ValueError("msg")
You don't only catch exceptions — you can raise them to reject bad input at the source. raise ValueError("...") stops the function and hands a clear message to whoever called it. This is how you enforce rules like "a table needs at least one person":
Choose a type that fits the problem: ValueError for a value that's the wrong shape/content, TypeError for the wrong type, KeyError/IndexError for missing lookups. A precise type + a clear message makes your function honest about what it needs.
Try it: Call
set_people(3)inside thetryinstead. It returns3with no exception, so theexceptis skipped and nothing prints.
Part 7 — finally for cleanup that must always run
The killer use of finally is releasing a resource — closing a file, a network connection, or an open tab — whether or not the work succeeded. Here a charge might fail, but the tab must close either way:
The tab closed before the exception propagated up to the outer handler. Without finally, that raise would have skipped the cleanup and leaked the resource.
Note: In real code you'd usually use a
withstatement (a context manager) for files and connections — it'sfinallycleanup done automatically. But knowing the underlyingfinallyguarantee is what makeswithclick.
Part 8 — The design decision: guard vs. crash
Same broken input, two valid strategies. Guard (catch and return a safe default) when the caller is a live UI that should stay calm. Raise when the caller passed something genuinely invalid and should be told to fix it. Compare a strict version against the guarded safe_split you're about to build:
For our playground app the answer is guard: a half-typed form with 0 people shouldn't crash the screen — it should quietly show 0.0 until the user finishes typing. That's exactly the behaviour safe_split needs.
Watch out: "Guard everything" is not a rule — it's a trade-off. Guarding hides problems, which is great for transient UI states and terrible for programmer mistakes. Guard the expected, recoverable cases (empty field →
0.0); let the unexpected ones crash so you find them.
Part 9 — Idioms & real-world patterns
You can try/except/else/finally and raise. Here's how exceptions look in real
Python codebases.
Catch several types at once
Group related errors in one except with a tuple:
Custom exceptions — name your failures
Subclass Exception so callers can catch exactly your error and not swallow
unrelated ones:
Coming from Java: like a custom exception class — but Python never forces you to declare (
throws) or catch it.
❓ Cross-question — "Are these checked exceptions?" No — Python has no checked exceptions and no
throwsclause (same as TypeScript). You catch by type (except ValueError), catch several with a tupleexcept (A, B), or broadly withexcept Exception. Nothing forces you to handle anything; unhandled errors just propagate up.
with — context managers clean up for you
A with block guarantees teardown even if the body raises. Write your own with
contextlib.contextmanager:
Coming from Java/JS:
withis Java's try-with-resources / C#'susing— it replaces the manualtry/finally: close()dance.
❓ Cross-question — "Is
withthe new TypeScriptusing?" Same idea — deterministic cleanup, exactly like TS 5.2'susing+Symbol.dispose. The object's__enter__/__exit__run on entry/exit even if the body throws, so the resource is always released.
EAFP vs LBYL — the Python philosophy
- LBYL ("Look Before You Leap"): check first —
if key in d: ... - EAFP ("Easier to Ask Forgiveness than Permission"): just try it, handle the failure. Python prefers EAFP — often faster and race-condition-free:
❓ Cross-question — "Isn't
tryonly for exceptional cases? I'd validate first." That's the LBYL habit from many languages. Python leans EAFP — try the operation, handle the failure — because it's often faster and avoids a check-then-act race (the key could vanish between theifand the access). Reach for EAFP first.
Real-world — parse a batch, collecting failures instead of crashing on the first bad row:
⚡ Advanced — chaining and re-raising
raise New(...) from e keeps the original cause in the traceback. A bare raise
re-throws the current exception unchanged (log, then let it propagate):
❓ Cross-question — "Is
raise X from elikethrow new Error(msg, {cause: e})?" Exactly — it attaches the original error as the cause (e.__cause__), so the traceback shows both layers. To re-throw the current error unchanged, use a bareraise(like a barethrow;) — it preserves the original stack.
Gotcha: a
returninsidefinallyswallows any exception or return from thetrybody. Keepfinallyfor cleanup only — neverreturnfrom it.
🎯 Your turn
Write safe_split(total, people) — it returns each person's share, or 0.0 if people is 0 (it never crashes):
safe_split(100, 4)→25.0safe_split(100, 0)→0.0
Hint — put the division in try: and catch except ZeroDivisionError: returning 0.0.
Then press ▶ Run, tap the Live App try chips to call it with different inputs, and hit ✓ Check. Green = this piece of the app is built. ✅
