My Notes
Python Exception Handling - The Complete Breakdown
Python Exception Handling — The Complete Breakdown
So what even is an exception?
Your code is running fine. Then something goes wrong — a user types text where you expected a number, a file doesn't exist, you divide by zero, a network connection drops. Python hits that line and has no idea what to do with it.
So it raises an exception. That's Python's way of saying "something broke and I'm stopping here." If you don't handle it, the entire program crashes with a red error message and a traceback. Every line after the crash never runs.
Exception handling is how you take back control. Instead of letting the program die, you say "if something goes wrong here, do this instead." The program keeps running. The user gets a real message. You stay in charge.
The basic structure — try / except
try:
result = 10 / 0
except ZeroDivisionError:
print("Can't divide by zero.")
Output:
Can't divide by zero.
Without the try/except, this crashes:
ZeroDivisionError: division by zero
With it, Python tries the code in try. The moment it hits an error, it jumps straight to except and runs that block instead. The crash never happens.
finally — the block that always runs
finally runs no matter what — whether the try succeeded, whether an exception was caught, whether you're about to crash. It's for cleanup code that must happen regardless.
try:
number = int("hello")
except ValueError:
print("That's not a number.")
finally:
print("This always runs.")
Output:
That's not a number.
This always runs.
Even if no error occurs:
try:
number = int("42")
print(f"Got: {number}")
except ValueError:
print("That's not a number.")
finally:
print("This always runs.")
Output:
Got: 42
This always runs.
finally is where you close files, disconnect from databases, release resources. Things that must happen even if everything explodes.
else — runs only if no exception occurred
else in a try block is the opposite of except. It runs only when the try block succeeded with no errors.
try:
number = int("42")
except ValueError:
print("Invalid input.")
else:
print(f"Success! Got {number}.")
finally:
print("Done.")
Output:
Success! Got 42.
Done.
With bad input:
try:
number = int("hello")
except ValueError:
print("Invalid input.")
else:
print(f"Success! Got {number}.")
finally:
print("Done.")
Output:
Invalid input.
Done.
The else block was skipped because an exception occurred. The full structure: try → except (if error) → else (if no error) → finally (always).
Catching specific exceptions
Always catch the most specific exception you can. Catching everything blindly hides real bugs.
try:
value = int(input("Enter a number: "))
result = 100 / value
except ValueError:
print("That wasn't a number.")
except ZeroDivisionError:
print("Can't divide by zero.")
If the user types "hello" → ValueError gets caught.
If the user types 0 → ZeroDivisionError gets caught.
If the user types 5 → no exception, runs clean.
Python checks except blocks in order — top to bottom — and stops at the first match.
Catching multiple exceptions in one line
try:
value = int(input("Enter a number: "))
result = 100 / value
except (ValueError, ZeroDivisionError):
print("Invalid input — enter a non-zero number.")
Both exceptions handled by one block. Use this when the response is the same regardless of which error occurred.
Catching any exception — Exception
try:
risky_operation()
except Exception as e:
print(f"Something went wrong: {e}")
Exception is the base class for almost all built-in errors. Catching it catches nearly everything. The as e part stores the actual error object so you can inspect it.
try:
result = 10 / 0
except Exception as e:
print(f"Error type: {type(e).__name__}")
print(f"Error message: {e}")
Output:
Error type: ZeroDivisionError
Error message: division by zero
Use this sparingly. Broad catches hide bugs. Be as specific as you can — catch Exception only as a last resort or for logging.
The exception hierarchy — how Python organises errors
Python's exceptions are a tree. Every exception inherits from BaseException. Most inherit from Exception. Catching a parent class catches all its children.
BaseException
├── SystemExit
├── KeyboardInterrupt
└── Exception
├── ValueError
├── TypeError
├── ZeroDivisionError
├── IndexError
├── KeyError
├── FileNotFoundError
├── AttributeError
├── ImportError
├── OSError
└── ... (dozens more)
Never catch BaseException — it swallows KeyboardInterrupt (Ctrl+C) and SystemExit, which means your program won't even respond to being killed.
The most common built-in exceptions
| Exception | When it happens | Example |
|---|---|---|
ValueError | Right type, wrong value | int("hello") |
TypeError | Wrong type entirely | "5" + 5 |
ZeroDivisionError | Divide by zero | 10 / 0 |
IndexError | List index out of range | [1,2,3][10] |
KeyError | Dict key doesn't exist | d["missing"] |
FileNotFoundError | File doesn't exist | open("nope.txt") |
AttributeError | Method/attribute doesn't exist | "hello".push("x") |
ImportError | Module not found | import nonexistent |
NameError | Variable not defined | print(undefined_var) |
RecursionError | Infinite recursion | Function calling itself forever |
PermissionError | No access rights | Reading a protected file |
TimeoutError | Operation took too long | Network request timeout |
OverflowError | Number too large | Float math overflow |
MemoryError | Out of memory | Allocating too much data |
Raising exceptions — throwing your own errors
You can raise exceptions yourself with raise. This is how you enforce rules in your own code.
def set_age(age):
if age < 0:
raise ValueError("Age can't be negative.")
return age
try:
set_age(-5)
except ValueError as e:
print(f"Error: {e}")
Output:
Error: Age can't be negative.
You're not just catching exceptions — you're creating them when your own logic detects something wrong. This is how well-written libraries communicate bad inputs to whoever calls them.
Re-raising an exception
Sometimes you want to catch an error, do something (log it, clean up), then let it keep bubbling up.
try:
result = 10 / 0
except ZeroDivisionError as e:
print(f"Logging error: {e}")
raise
raise with no arguments re-raises the current exception. The error still crashes the program — you just got to do something before it did.
Custom exceptions — building your own error types
For any serious project, you'll want your own exception types so callers can catch your specific errors separately from everything else.
class InsufficientFundsError(Exception):
def __init__(self, amount, balance):
self.amount = amount
self.balance = balance
super().__init__(f"Tried to withdraw {amount} but balance is {balance}.")
def withdraw(balance, amount):
if amount > balance:
raise InsufficientFundsError(amount, balance)
return balance - amount
try:
new_balance = withdraw(100, 250)
except InsufficientFundsError as e:
print(f"Transaction failed: {e}")
print(f"You tried: ${e.amount}, Available: ${e.balance}")
Output:
Transaction failed: Tried to withdraw 250 but balance is 100.
You tried: $250, Available: $100
Custom exceptions make your code communicate clearly. Instead of a generic ValueError, callers know exactly what went wrong and can handle it precisely.
Context managers — with statement (the clean way to handle resources)
Opening files, database connections, and network sockets need to be closed even if something crashes. finally works, but Python has a cleaner tool: with.
The manual way:
try:
f = open("data.txt", "r")
content = f.read()
print(content)
finally:
f.close()
The clean way:
with open("data.txt", "r") as f:
content = f.read()
print(content)
with automatically closes the file when the block ends — even if an exception occurs inside. No finally needed. This is the standard way to work with files in Python.
With exception handling on top:
try:
with open("data.txt", "r") as f:
content = f.read()
print(content)
except FileNotFoundError:
print("File doesn't exist.")
assert — the quick sanity check
assert checks that something is true. If it's not, it raises an AssertionError. Used for catching logic bugs during development.
def divide(a, b):
assert b != 0, "Divisor can't be zero"
return a / b
divide(10, 0)
Output:
AssertionError: Divisor can't be zero
Assertions are for developer errors — things that should never happen if the code is correct. They're not for user input validation (use raise ValueError for that). Also note: assertions can be disabled globally with the -O flag, so never rely on them for security or critical logic.
Alternatives to exception handling
Option 1: LBYL — "Look Before You Leap"
Check the condition before doing the operation. Avoid the exception entirely.
# LBYL
def safe_divide(a, b):
if b == 0:
return None
return a / b
Option 2: EAFP — "Easier to Ask Forgiveness than Permission"
Just try it and catch the error if it happens. This is the Python-preferred style.
# EAFP
def safe_divide(a, b):
try:
return a / b
except ZeroDivisionError:
return None
Python culture generally prefers EAFP. It's often faster (no double-checking) and handles race conditions better (the file might exist when you check but disappear before you open it). But LBYL is fine for simple cases — pick whichever is clearer.
Option 3: Return error codes
Some languages return error codes instead of raising exceptions. In Python, this is rare and usually a bad idea — it's easy to ignore a return value, hard to ignore a crash.
# not pythonic — avoid this
def safe_divide(a, b):
if b == 0:
return None, "division by zero"
return a / b, None
result, error = safe_divide(10, 0)
if error:
print(f"Error: {error}")
This works but clutters every function call with error checking. Exceptions are cleaner.
Option 4: contextlib.suppress — silently ignore specific errors
For cases where you genuinely don't care if something fails:
from contextlib import suppress
with suppress(FileNotFoundError):
os.remove("temp_file.txt")
If the file doesn't exist, the error is silently ignored. Use sparingly — suppressing errors can hide real problems.
All the scenarios — when to use what
User input that might be invalid → try/except ValueError
def get_age():
try:
age = int(input("Enter your age: "))
if age < 0:
raise ValueError("Age can't be negative.")
return age
except ValueError as e:
print(f"Invalid age: {e}")
return None
Opening files that might not exist → try/except FileNotFoundError
def read_config(path):
try:
with open(path, "r") as f:
return f.read()
except FileNotFoundError:
print(f"Config file not found: {path}")
return {}
Network requests that might fail → try/except with timeout handling
import requests
def fetch_data(url):
try:
response = requests.get(url, timeout=5)
response.raise_for_status()
return response.json()
except requests.exceptions.Timeout:
print("Request timed out.")
except requests.exceptions.HTTPError as e:
print(f"HTTP error: {e}")
except requests.exceptions.ConnectionError:
print("Connection failed.")
return None
Database operations → try/except/finally to always close connection
def query_db(sql):
conn = None
try:
conn = connect_to_db()
result = conn.execute(sql)
return result
except DatabaseError as e:
print(f"Query failed: {e}")
return None
finally:
if conn:
conn.close()
Enforcing your own rules → raise
def set_password(password):
if len(password) < 8:
raise ValueError("Password must be at least 8 characters.")
if not any(c.isdigit() for c in password):
raise ValueError("Password must contain at least one number.")
return password
Real project — a safe file parser with full exception handling
Here's a complete mini-project that puts everything together. A program that reads a file of scores, parses them, calculates stats, and handles every possible failure gracefully.
class ScoreParseError(Exception):
"""Raised when a score line can't be parsed."""
pass
def parse_score_line(line):
"""Parse a line like 'Alice:95' into (name, score)."""
line = line.strip()
if not line or line.startswith("#"):
return None
if ":" not in line:
raise ScoreParseError(f"Invalid format (missing ':'): '{line}'")
parts = line.split(":")
if len(parts) != 2:
raise ScoreParseError(f"Invalid format (too many ':'): '{line}'")
name = parts[0].strip()
score_str = parts[1].strip()
if not name:
raise ScoreParseError(f"Empty name in line: '{line}'")
try:
score = int(score_str)
except ValueError:
raise ScoreParseError(f"Score is not a number: '{score_str}'")
if not 0 <= score <= 100:
raise ScoreParseError(f"Score out of range (0-100): {score}")
return name, score
def load_scores(filepath):
"""Load all scores from a file. Returns list of (name, score) tuples."""
scores = []
errors = []
try:
with open(filepath, "r") as f:
lines = f.readlines()
except FileNotFoundError:
print(f"File not found: {filepath}")
return [], []
except PermissionError:
print(f"Permission denied: {filepath}")
return [], []
for i, line in enumerate(lines, start=1):
try:
result = parse_score_line(line)
if result is not None:
scores.append(result)
except ScoreParseError as e:
errors.append(f"Line {i}: {e}")
return scores, errors
def calculate_stats(scores):
"""Calculate average, highest, and lowest score."""
if not scores:
raise ValueError("No scores to calculate stats for.")
values = [score for _, score in scores]
return {
"count": len(values),
"average": sum(values) / len(values),
"highest": max(values),
"lowest": min(values),
}
def run_report(filepath):
"""Full pipeline — load, parse, calculate, report."""
print(f"Loading scores from: {filepath}\n")
scores, errors = load_scores(filepath)
if errors:
print("Parse errors encountered:")
for error in errors:
print(f" - {error}")
print()
if not scores:
print("No valid scores found. Exiting.")
return
print(f"Valid scores loaded: {len(scores)}")
for name, score in scores:
print(f" {name}: {score}")
print()
try:
stats = calculate_stats(scores)
print(f"Count: {stats['count']}")
print(f"Average: {stats['average']:.1f}")
print(f"Highest: {stats['highest']}")
print(f"Lowest: {stats['lowest']}")
except ValueError as e:
print(f"Stats error: {e}")
finally:
print("\nReport complete.")
run_report("scores.txt")
If scores.txt contains:
# Class scores
Alice:95
Bob:87
Charlie:hello
Dave:110
Eve:72
:55
Frank:88
Output:
Loading scores from: scores.txt
Parse errors encountered:
- Line 4: Score is not a number: 'hello'
- Line 5: Score out of range (0-100): 110
- Line 6: Empty name in line: ':55'
Valid scores loaded: 4
Alice: 95
Bob: 87
Eve: 72
Frank: 88
Count: 4
Average: 85.5
Highest: 95
Lowest: 72
Report complete.
The program never crashed. Every bad line was caught individually. The good data still got processed. The user got a clear report of what went wrong and what succeeded. That's exception handling doing its job.
The decision table
| Situation | Use |
|---|---|
| Risky operation that might fail | try/except |
| Cleanup that must always happen | finally |
| Code that runs only on success | else |
| Multiple different errors, different responses | Multiple except blocks |
| Multiple errors, same response | except (Error1, Error2) |
| Catch anything (last resort) | except Exception as e |
| File/connection resources | with statement |
| Enforce your own rules | raise ValueError(...) |
| Your own error category | Custom exception class |
| Silently ignore one specific error | contextlib.suppress |
| Development sanity checks | assert |
The one-line summary
try runs risky code. except catches specific failures and handles them gracefully. finally always cleans up. raise lets you create your own errors. Custom exceptions give your code a clear, specific vocabulary for what went wrong. Master these and your programs stop crashing and start communicating.