Exception Handling
try/except/else/finally in full, catching specific exceptions, the exception hierarchy, raising and chaining exceptions, and writing your own exception classes.
What Happens Without Exception Handling — and How try/except Fixes It
By default, when Python hits a runtime error — dividing by zero, accessing a missing dictionary key, converting text that isn't a valid number — it raises an exception, and unless something catches it, the program stops immediately and prints a traceback. For a script that processes many independent items, one bad item shouldn't be allowed to bring down the whole run.
amounts = ["120.50", "89.99", "not-a-number", "45.00"]
total = 0
for amount in amounts:
total += float(amount) # crashes on the third item
print(total) # never reached — the program already terminated
# ValueError: could not convert string to float: 'not-a-number'try/except lets you catch an exception where it happens and decide what to do next, instead of letting it propagate all the way up and kill the program.
amounts = ["120.50", "89.99", "not-a-number", "45.00"]
total = 0
skipped = 0
for amount in amounts:
try:
total += float(amount)
except ValueError:
skipped += 1
print(f"Skipping invalid amount: {amount!r}")
print(f"Total: {total}, skipped: {skipped}")
# Skipping invalid amount: 'not-a-number'
# Total: 255.49, skipped: 1The code inside try: runs normally until (and unless) an exception occurs. The moment one is raised, Python immediately jumps to the matching except block — any remaining lines in the try block are skipped entirely, not just the line that failed.
The Four Clauses — What Each One Is Actually For
Most tutorials stop at try/except and skip the other two clauses entirely — but else and finally both solve real, specific problems, and knowing when to reach for each one is what separates tutorial-level exception handling from the way experienced engineers actually write it.
try:
result = risky_operation()
except SomeError:
# runs ONLY if the try block raised SomeError
handle_the_error()
else:
# runs ONLY if the try block completed with NO exception at all
use_the_result(result)
finally:
# ALWAYS runs — whether an exception occurred, was caught, wasn't caught,
# or the try block succeeded cleanly. Even if the except block itself
# raises a new exception, finally still runs before it propagates.
cleanup()else — code that should run only when nothing went wrong
It is tempting to just put the "success" code at the end of the try block instead of using else — but that quietly changes what gets caught. Code inside try is protected by the except clauses below it; code inside else is not. This distinction matters the moment the "success" code can itself raise the same kind of exception you were trying to catch.
# Without else — a bug hiding in plain sight
try:
data = json.loads(text)
process(data) # if process() ALSO raises a ValueError, it gets
except ValueError: # incorrectly caught here too, as if parsing had failed
print("Invalid JSON")
# With else — only json.loads() failures are caught;
# any error from process() propagates normally, as it should
try:
data = json.loads(text)
except ValueError:
print("Invalid JSON")
else:
process(data)finally — guaranteed cleanup, no matter what happens
finally runs unconditionally — this is precisely the mechanism that makes with open(...), from the Reading & Writing Files module, work: internally, a context manager's cleanup step is guaranteed using logic equivalent to a finally block, which is exactly why the file gets closed even when an exception is raised partway through.
def process_file(path):
f = open(path)
try:
return risky_parse(f)
finally:
f.close() # runs whether risky_parse() succeeds, raises, or anything in between
# This is conceptually what "with open(path) as f:" does for you automatically —
# which is exactly why "with" is still preferred over writing this by hand.Catching Specific Exceptions vs the Bare except: Anti-Pattern
except: with no exception type at all will catch everything — not just the error you were expecting, but genuine programming mistakes, typos, and even the exceptions Python uses internally to implement Ctrl+C and program exit. This is one of the most consequential anti-patterns in exception handling, and it is worth understanding exactly why.
try:
process_order(order)
except:
print("Something went wrong")
# This catches EVERYTHING, including:
# - a genuine bug like a typo: proces_order(order) (NameError)
# - passing the wrong type entirely: order.total() (TypeError, if total is not callable)
# - Ctrl+C during a long-running operation (KeyboardInterrupt)
# - the process being asked to exit cleanly (SystemExit)
#
# All of these get reduced to the same unhelpful message,
# and the ORIGINAL bug is now invisible.try:
process_order(order)
except (ValueError, KeyError) as e:
print(f"Order data was invalid: {e}")
log_error(order, e)
# A genuine bug (NameError, TypeError from your own code) is NOT caught here —
# it propagates normally, with a full traceback, so it gets noticed and fixed
# instead of silently disappearing behind a generic error message.except Exception: instead of bare except: — covered next, in Part 04 — which at least excludes the small set of system-level exceptions that should almost never be intercepted.Exception, ValueError, KeyError, and How They Relate
Every exception in Python is a class, and exception classes form an inheritance hierarchy — a concept you will cover formally in the Object-Oriented Python phase, but the practical implication matters right now: catching a parent class also catches every one of its subclasses.
BaseException
├── SystemExit
├── KeyboardInterrupt
└── Exception # the class almost everything you catch inherits from
├── ValueError # right type, invalid value — e.g. int("hello")
├── TypeError # operation on the wrong type entirely
├── KeyError # dict key that doesn't exist
├── IndexError # list/sequence index out of range
├── AttributeError # accessing an attribute/method that doesn't exist
├── FileNotFoundError # a subclass of OSError — file doesn't exist
└── ZeroDivisionError # division or modulo by zerotry:
value = int(user_input)
except Exception:
# catches ValueError, TypeError, and literally any other Exception subclass —
# broader than you usually want, but still excludes BaseException's other branches
print("Something went wrong parsing input")Why you almost never catch BaseException directly
Exception is itself a subclass of BaseException — but BaseException also covers KeyboardInterrupt (raised when a user presses Ctrl+C) and SystemExit (raised by sys.exit()). Catching BaseException directly means your program can no longer be interrupted or exited cleanly through the normal mechanisms — a genuinely dangerous thing to do by accident, and virtually never what you actually want. Catch Exception, or a specific subclass of it — not BaseException.
except clauses top to bottom and uses the first one that matches. If you list a parent class (like Exception) before a more specific subclass (like ValueError) in separate except blocks, the specific one will never be reached — it's already caught by the broader one above it. List more specific exception types first.raise — Signaling That Something Is Wrong, Deliberately
Exceptions aren't only something that happens to you from built-in operations — you can, and often should, raise them yourself, the moment your own code detects that something is invalid, rather than letting bad data propagate further and fail confusingly somewhere else.
def set_discount(percent):
if not 0 <= percent <= 100:
raise ValueError(f"Discount must be between 0 and 100, got {percent}")
return percent
set_discount(150)
# ValueError: Discount must be between 0 and 100, got 150This is the real-world counterpart to the assert statement from the Control Flow module. assert is for catching your own programming mistakes during development, and can be globally stripped out. raise with a specific, meaningful exception type is how you validate data that genuinely matters at runtime — user input, function arguments, data read from a file or an API — and it is never optimized away.
Choose the exception type that best describes the problem
Raising the right built-in exception type — rather than always reaching for a generic Exception — lets callers catch precisely the failure category they care about, exactly as covered in Part 04.
def get_user(user_id):
if not isinstance(user_id, int):
raise TypeError(f"user_id must be an int, got {type(user_id).__name__}")
user = database.lookup(user_id)
if user is None:
raise KeyError(f"No user found with id {user_id}")
return userBare raise, and raise ... from ... for Exception Chaining
Sometimes you need to catch an exception — to log it, clean something up, or add context — and then let it continue propagating rather than swallowing it. A bare raise with no argument, used inside an except block, re-raises the exact same exception that was just caught, including its original traceback.
def process_payment(order):
try:
charge_card(order)
except PaymentError as e:
log_error(f"Payment failed for order {order.id}: {e}")
raise # re-raises the SAME PaymentError — the caller still sees it,
# and still gets the full original tracebackraise ... from ... — deliberately chaining a new exception to its cause
Sometimes the right move isn't to re-raise the same exception, but to raise a different, more meaningful one in its place — while still preserving the original as context. raise NewError(...) from original_error does exactly this, and Python's traceback shows both, clearly labeled, rather than losing the original cause.
def load_config(path):
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except json.JSONDecodeError as e:
raise ValueError(f"Config file {path} contains invalid JSON") from e
# The resulting traceback shows both exceptions:
# json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
#
# The above exception was the direct cause of the following exception:
#
# ValueError: Config file settings.json contains invalid JSONfrom, Python still shows both exceptions in the traceback (labeled "during handling of the above exception, another exception occurred"), but from makes the causal relationship explicit and intentional, which is clearer for whoever reads the traceback later — usually you, at 2am, debugging a production incident.Writing Your Own Exception Classes — A Banking Example
Built-in exceptions like ValueError are genuinely useful, but they describe generic problems — an invalid value, of some kind, for some reason. A custom exception class lets you describe a failure in terms specific to your own program's domain, which makes both the raising code and the catching code more expressive.
class InsufficientFundsError(Exception):
"""Raised when a withdrawal would take an account below zero."""
pass
class Account:
def __init__(self, balance):
self.balance = balance
def withdraw(self, amount):
if amount > self.balance:
raise InsufficientFundsError(
f"Cannot withdraw ${amount:.2f} — balance is only ${self.balance:.2f}"
)
self.balance -= amount
return self.balanceA custom exception is just a class that inherits from Exception (directly or indirectly) — often with no additional code at all, since it primarily exists to give the error a distinct, catchable identity. The full mechanics of class definitions and inheritance are covered properly in the upcoming Object-Oriented Python phase; what matters here is that this pattern is genuinely this simple to use today.
account = Account(balance=100)
try:
account.withdraw(250)
except InsufficientFundsError as e:
print(f"Transaction declined: {e}")
notify_customer(account, reason="insufficient_funds")
# Transaction declined: Cannot withdraw $250.00 — balance is only $100.00Attaching extra data to a custom exception
A custom exception can carry more than just a message — genuinely useful when the code catching it needs structured details, not just human-readable text, to decide what to do next.
class InsufficientFundsError(Exception):
def __init__(self, requested, available):
self.requested = requested
self.available = available
super().__init__(
f"Cannot withdraw ${requested:.2f} — balance is only ${available:.2f}"
)
try:
account.withdraw(250)
except InsufficientFundsError as e:
shortfall = e.requested - e.available
print(f"Short by ${shortfall:.2f}") # the caller can react to the SPECIFIC numbers,
# not just parse the error message as textHow the with Statement Relates to Exception Handling
The Reading & Writing Files module introduced with open(...) as f: and promised this connection would be made explicit here: a context manager's guarantee — that cleanup always runs, exception or not — is built on exactly the same mechanism as finally, covered in Part 02 above.
# This:
with open("data.txt") as f:
process(f)
# Behaves equivalently to this, written by hand:
f = open("data.txt")
try:
process(f)
finally:
f.close()
# In both versions, f.close() runs whether process() succeeds,
# raises an exception, or does anything in between.This is precisely why with is the preferred pattern for anything that needs guaranteed cleanup — not just files, but database connections, network sockets, and locks in concurrent code, all of which follow the same "acquire, use, guarantee release" shape. The full mechanics of building your own context manager — the __enter__ and __exit__ methods that make this work — are covered in depth in the Context Managers module later in this track; the important idea for now is that with is not a separate feature from exception handling, it is built on exception handling.
__exit__ method is called with details about any exception that occurred inside the with block — including the option to suppress it entirely, rather than letting it propagate. This is exactly how contextlib.suppress() works, a small standard-library helper for cleanly ignoring a specific, expected exception type, which you will meet properly in the Context Managers module.The Bare except: That Hid a Real Bug for Three Weeks — Minneapolis, MN
A scheduling platform's nightly job sends appointment reminder emails to patients. For three weeks, a growing number of patients silently stopped receiving reminders — no alerts fired, no errors appeared in any dashboard, because the job itself reported success every single night.
What the postmortem finds
def send_reminders(appointments):
sent = 0
for appt in appointments:
try:
send_email(appt.patient_email, build_reminder(appt))
sent += 1
except:
pass # "just skip anything that fails, don't crash the whole job"
return sentThe except: was added months earlier, deliberately, to stop one specific transient issue — an occasional email-provider timeout — from crashing the entire nightly batch over a single patient. It worked, in the narrow sense that the job never crashed again. But it also caught everything else that could possibly go wrong inside the loop, forever, with no distinction.
The real bug: a recent change to build_reminder() introduced a KeyError for a small subset of appointments missing a newly-added field. That exception was real, genuinely worth investigating — and the bare except: quietly absorbed it every night, indistinguishable from the harmless timeout it was originally written for.
def send_reminders(appointments):
sent, failed = 0, 0
for appt in appointments:
try:
send_email(appt.patient_email, build_reminder(appt))
sent += 1
except EmailTimeoutError as e:
failed += 1
log_error(f"Timed out sending to {appt.patient_email}: {e}")
# anything else — a KeyError from build_reminder(), a bug, anything unexpected —
# is no longer caught here at all, and will surface immediately and loudly
if failed:
alert_oncall(f"{failed} reminder(s) failed to send")
return sentThis is the precise cost of the bare except: anti-pattern described in Part 03: it doesn't just risk hiding bugs in the abstract, it genuinely did — for three weeks, in a system where a missed reminder has a real, direct impact on patients. The team's follow-up added a lint rule that flags bare except: and except Exception: without a specific reason comment, enforced in CI going forward.
Four Misconceptions About Exception Handling
5 Interview Questions — With Complete Answers
Exception Handling Mistakes Beginners Make Constantly
Errors You Will Hit With Exception Handling — And Exactly Why
🎯 Key Takeaways
- ✓try/except lets you catch a runtime error where it happens instead of letting it crash the whole program.
- ✓else runs only when the try block completes with no exception at all, and is NOT protected by the except clauses above it — the correct place for "success only" code.
- ✓finally always runs — exception or not, caught or not — and is the mechanism guaranteed cleanup (like with open()) is built on.
- ✓Never use a bare except: — it catches genuine bugs alongside the failure you meant to handle, and can hide serious problems for a long time, exactly as shown in the Real World example.
- ✓Exception classes form a hierarchy — catching a parent class (like Exception) also catches every subclass beneath it. List more specific exception types before broader ones.
- ✓Never catch BaseException directly — it includes KeyboardInterrupt and SystemExit, which should almost never be intercepted.
- ✓raise with a specific exception type is the correct way to validate real runtime data — unlike assert, it is never stripped out by Python's optimization flag.
- ✓A bare raise inside an except block re-raises the same exception; raise NewError(...) from original re-raises a different, more meaningful exception while preserving the original as its documented cause.
- ✓Custom exception classes — even a one-line class inheriting from Exception — give a specific failure a distinct, catchable identity, and can carry structured data beyond just a message.
What comes next
Module 18 covers how real Python projects are actually structured — the import system in full, how packages work, the if __name__ == "__main__" idiom, and building a proper requirements.txt.
Module 18 → Modules, Packages & Virtual EnvironmentsDiscussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.