Lesson 21: Exception Handling — Fail Gracefully
try/except/else/finally, raising your own errors, and writing code that never crashes the user.
Errors Are Information
When Python hits a problem, it raises an exception. Unhandled, an exception crashes the program with an ugly traceback. Handled well, your program responds gracefully — this is the difference between professional and fragile software.
Nobody writes bug-free code. Professionals write code that anticipates failure and handles it with dignity. Every real program — websites, games, banking apps — leans on exception handling to survive bad input, missing files, and network hiccups.
What You'll Learn in This Lesson
- Protect risky code with
try/except - Use
elseandfinallycorrectly - Raise your own errors with
raise - Create custom exception classes
The try/except Shield
try: wraps risky code. If an exception occurs, execution jumps to the matching except block instead of crashing:
try:
result = 10 / 0
except ZeroDivisionError:
print("Cannot divide by zero")
result = None
print(result) # None — program kept running!
Catch specific types whenever possible:
try:
number = int(input("Enter a number: "))
except ValueError:
print("That was not a number!")
| Common exception | Happens when |
|---|---|
ValueError |
Wrong value (e.g., int("abc")) |
TypeError |
Wrong type (e.g., "a" + 1) |
FileNotFoundError |
Missing file |
KeyError |
Missing dict key |
IndexError |
Index out of range |
ZeroDivisionError |
Division by zero |
Mental model:
exceptblocks are labeled boxes that catch only their matching error type. AZeroDivisionErrorbox won't catch aValueError— each error falls into its own labeled box.
Catching multiple types — the tuple form:
try:
value = int(data["price"]) / items
except (KeyError, ValueError, ZeroDivisionError):
print("Bad data — skipping")
The Full Structure: try / except / else / finally
try:
risky_operation()
except ValueError as e:
print("Handled:", e) # runs ONLY on error
else:
print("Success!") # runs ONLY when no error
finally:
cleanup() # ALWAYS runs — even on errors
| Block | Runs when |
|---|---|
try |
Always (the risky code) |
except |
An exception was raised |
else |
No exception happened |
finally |
Always — for cleanup |
Mental model:
exceptblocks are labeled boxes that catch only their matching error type.finallyis the janitor — it shows up no matter what.
Why have both else and finally? else runs only on success — so you don't accidentally handle the success path inside the try where an exception there would be caught too. finally runs unconditionally — the right place for closing files, releasing locks, and cleanup.
Raise Your Own Errors
raise lets you enforce rules and give users clear feedback:
def set_age(age):
if age < 0:
raise ValueError("Age cannot be negative")
return age
try:
set_age(-5)
except ValueError as e:
print("Caught:", e) # Caught: Age cannot be negative
Why raise instead of returning None? A raised error forces the caller to deal with the problem — it can't be silently ignored. Returning None often leads to a NoneType crash three lines later, far from the real bug. Raise early, raise loudly.
Custom Exceptions — Domain Errors
Subclass Exception to define errors that mean something in your domain:
class NotEnoughStock(Exception):
pass
def order(quantity, stock=5):
if quantity > stock:
raise NotEnoughStock(f"Only {stock} left")
return "Order placed"
try:
print(order(10))
except NotEnoughStock as e:
print("Caught:", e) # Caught: Only 5 left
Custom exceptions are self-documenting and catchable precisely.
When to define a custom exception: when the same error can occur in many places and you want to catch it specifically, or when the error is unique to your domain (a game: GameOver; a store: OutOfStock). A hierarchy helps large codebases:
class StoreError(Exception): ...
class OutOfStock(StoreError): ...
class InvalidPayment(StoreError): ...
Now except StoreError catches every store problem while still letting you handle OutOfStock specifically.
Common Mistakes to Avoid
- Mistake: Bare
except:— Fix: it hides bugs and even catchesKeyboardInterrupt. Always name the exception type. - Mistake: Catching
Exceptionbroadly at the top — Fix: catch specific types first, broad ones last. - Mistake: Swallowing errors silently (
except: pass) — Fix: log or print the message — silent excepts are debugging nightmares. - Mistake: Raising inside
else— Fix:elseis for the success path; keep raises intryor normal flow. - Mistake: Returning
Noneon error instead of raising — Fix: raise early so the caller must handle it.
Professional Tips & Tricks
- Catch the most specific exception first, broad ones last.
- Never use a bare
except:— it hides bugs and catches even KeyboardInterrupt. - Log or print the exception message — silent except blocks are debugging nightmares.
- Use
elsefor success-only logic andfinallyfor guaranteed cleanup. - Raise domain-specific custom exceptions so callers can catch precisely.
Key Takeaways
try/exceptcatches errors and keeps the program alive.elseruns on success;finallyalways runs.raise ValueError("msg")enforces rules with clear feedback.- Subclass
Exceptionfor domain-specific errors. - Catch specific types; never use bare
except. - Raise early and loudly — never let errors vanish silently.
Next up: Iterators, generators & itertools — lazy, memory-friendly iteration.
# Exception handling in action
def divide(a, b):
try:
result = a / b
except ZeroDivisionError:
print("Error: cannot divide by zero")
return None
except TypeError:
print("Error: numbers required")
return None
else:
print("Division successful")
return result
finally:
print("Cleanup done")
print("10 / 2 =", divide(10, 2))
print("10 / 0 =", divide(10, 0))
# Raising with validation
def set_age(age):
if age < 0:
raise ValueError("Age cannot be negative")
return age
try:
set_age(-5)
except ValueError as e:
print("Caught:", e)Lesson Code (Python)
# Exception handling in action
def divide(a, b):
try:
result = a / b
except ZeroDivisionError:
print("Error: cannot divide by zero")
return None
except TypeError:
print("Error: numbers required")
return None
else:
print("Division successful")
return result
finally:
print("Cleanup done")
print("10 / 2 =", divide(10, 2))
print("10 / 0 =", divide(10, 0))
# Raising with validation
def set_age(age):
if age < 0:
raise ValueError("Age cannot be negative")
return age
try:
set_age(-5)
except ValueError as e:
print("Caught:", e)Console Output
Division successful
Cleanup done
10 / 2 = 5.0
Error: cannot divide by zero
Cleanup done
10 / 0 = None
Caught: Age cannot be negativeCode Visualization Tips
- Trace the flow: try -> (ok?) else -> finally. try -> (error!) except -> finally.
- Picture except blocks as labeled boxes that catch only their matching error type.
- Visualize finally as the janitor — it shows up no matter what happens.
Professional Tips & Tricks
- Catch the most specific exception first, broad ones last.
- Never use a bare except: — it hides bugs and catches even KeyboardInterrupt.
- Log or print the exception message — silent except blocks are debugging nightmares.
Python Code Judge & Practice Arena
LeetCode StyleRun real Python 3.12 WebAssembly code directly in your browser against automated test suites.
Problem 1: Safe Type Converter 1
Test Your Knowledge
Instant feedbackQuick Check: Lesson 21: Exception Handling, Custom Exceptions & Robust Systems
Up next · Continue learning
Iterators, Generators & itertools
Lazy iteration with yield, memory-efficient pipelines, and the itertools toolbox.