Python Exception Handling: try/except/else/finally, Raising Errors and Custom Exceptions
Key takeaways
Learn Python exceptions: try-except-else-finally, raising errors, custom exception classes, and safe patterns for files, I/O, and retries—with examples.
Introduction
Exception handling is central to building stable, maintainable Python programs. The core mental shift from beginner to intermediate Python is realizing that exceptions aren’t just “the thing that happens when code breaks” — they’re Python’s actual mechanism for handling anticipated failure conditions (a missing file, invalid user input, a flaky network call) in a way that’s separated from the main logic. Code that checks every possible failure inline (if not os.path.exists(...): ...) tends to bury the happy path under defensive branches; exceptions let you write the happy path plainly and handle failure at the boundary where you actually know what to do about it.
Basic exception handling
try-except
The order of except clauses matters when exception types are related by inheritance: Python checks them top to bottom and stops at the first match, so a broader parent class listed before a more specific subclass will silently swallow it and the specific handler never runs. This doesn’t come up in the flat example below (ValueError and ZeroDivisionError are unrelated), but it’s a real bug source once you’re catching things like OSError alongside its subclasses FileNotFoundError and PermissionError — the specific ones have to come first.
# Basic form
try:
result = 10 / 0
except ZeroDivisionError:
print("Cannot divide by zero")
result = None
# Multiple exception types
try:
number = int(input("Enter a number: "))
result = 10 / number
except ValueError:
print("Please enter a valid number")
except ZeroDivisionError:
print("Enter a non-zero number")
# Bind the exception object
try:
file = open('missing.txt', 'r')
except FileNotFoundError as e:
print(f"Error: {e}")
try-except-else-finally
Full structure
The else block is the piece people skip and just fold into try, but it exists for a real reason: code in try is “protected” — if it raises, Python routes to except — while code in else only runs after try has already succeeded without being protected itself. That means a bug in the else block (say, a TypeError while processing content) propagates normally instead of accidentally getting caught by an except FileNotFoundError clause that was only ever meant to guard the open() call. Putting “what to do with the result” in else rather than at the bottom of try keeps the scope of what each except clause is actually guarding narrow and intentional. finally runs whether or not an exception occurred, and even if the except block itself re-raises — it’s the one block that’s genuinely guaranteed to execute, which is why it’s the right place for cleanup that has to happen no matter what (though note the with statement — covered in the tips section below — usually makes manual finally cleanup unnecessary).
try:
# Code that might fail
file = open('data.txt', 'r')
content = file.read()
except FileNotFoundError:
# Runs when an exception occurs
print("File not found")
else:
# Runs only if no exception in try
print(f"Read OK: {len(content)} characters")
finally:
# Always runs (cleanup)
if 'file' in locals():
file.close()
print("Done")
Raising exceptions (raise)
Basic raise
raise is how a function signals “I can’t fulfill this call” to whoever called it, rather than trying to guess a sensible fallback value. Returning None or -1 for an invalid input is tempting but dangerous, because the caller has to remember to check for that sentinel every single time, and if they forget, the bad value silently propagates further into the program before failing somewhere confusing and disconnected from the real cause; raising immediately fails loudly, at the source of the actual problem.
def divide(a, b):
if b == 0:
raise ValueError("b cannot be zero")
return a / b
try:
result = divide(10, 0)
except ValueError as e:
print(f"Error: {e}")
Re-raising
def process_data(data):
try:
result = int(data)
except ValueError:
print("Conversion failed")
raise # propagate to caller
try:
process_data("abc")
except ValueError:
print("Handled at outer level")
Bare raise (with no argument) inside an except block is specifically for re-raising the exception that’s currently being handled, preserving its original traceback — which is different from raise ValueError("..."), which would create a brand-new exception and lose the trail back to where the original failure actually happened. This pattern is common when a function wants to log or react to a failure locally (print a message, increment a retry counter) but still let the caller decide how to ultimately handle it, rather than swallowing the error at the point it was first noticed.
Custom exceptions
User-defined exceptions
Custom exception classes earn their keep once a codebase has more than a couple of distinct failure modes that callers need to tell apart and react to differently — catching a specific InsufficientBalanceError lets calling code branch on “not enough funds” without string-matching a generic error message, and storing balance/amount on the instance (rather than only formatting them into the message string) lets the catching code programmatically inspect how much was missing instead of just displaying it. Subclassing Exception (not BaseException directly) is the convention because BaseException’s other direct subclasses — SystemExit, KeyboardInterrupt — represent program-termination signals that a broad except Exception handler is specifically supposed to let through uncaught.
class InsufficientBalanceError(Exception):
"""Raised when withdrawal exceeds balance."""
def __init__(self, balance, amount):
self.balance = balance
self.amount = amount
super().__init__(f"Insufficient balance: {balance} (needed: {amount})")
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner
self.balance = balance
def withdraw(self, amount):
if amount > self.balance:
raise InsufficientBalanceError(self.balance, amount)
self.balance -= amount
return self.balance
# Usage
account = BankAccount("Alice", 10000)
try:
account.withdraw(15000)
except InsufficientBalanceError as e:
print(e) # Insufficient balance: 10000 (needed: 15000)
print(f"Current balance: {e.balance}")
Common built-in exceptions
Frequently used types
Knowing which built-in exception a given failure raises is what makes except SpecificType practical instead of falling back to catching everything — int("abc") always raises ValueError (the value is the wrong kind, not the wrong type: a string is a valid argument to int(), just not this particular string), while "hello" + 5 raises TypeError (string and int genuinely can’t be combined with +, regardless of the int’s value). KeyError and IndexError are the dict/list-specific counterparts of “this lookup doesn’t exist” — dict.get('key', default) and bounds-checking before indexing a list are common ways to sidestep them entirely when a missing entry is an expected, not exceptional, case.
# ValueError: wrong value
try:
int("abc")
except ValueError:
print("Conversion failed")
# TypeError: wrong types
try:
"hello" + 5
except TypeError:
print("Type mismatch")
# KeyError: missing dict key
try:
data = {'name': 'Alice'}
print(data['age'])
except KeyError:
print("Key missing")
# IndexError: index out of range
try:
arr = [1, 2, 3]
print(arr[10])
except IndexError:
print("Index out of range")
# FileNotFoundError: missing file
try:
open('missing.txt', 'r')
except FileNotFoundError:
print("File not found")
Practical examples
Safe JSON file reading
Stacking three except clauses of increasing generality — specific file errors, then json.JSONDecodeError for malformed content, then a bare Exception catch-all — is a deliberate layering, not redundancy: it lets each failure mode get a tailored, informative message while still guaranteeing the function never crashes the caller on something unanticipated. The catch-all is doing real work here (returning {} rather than propagating), which is a legitimate choice specifically because the function’s whole contract is “safely read JSON, or hand back an empty result” — a catch-all is far more questionable in code whose job is to let real bugs surface.
import json
def safe_read_json(filename):
"""Read a JSON file safely."""
try:
with open(filename, 'r', encoding='utf-8') as f:
return json.load(f)
except FileNotFoundError:
print(f"File not found: {filename}")
return {}
except json.JSONDecodeError as e:
print(f"JSON parse error: {e}")
return {}
except Exception as e:
print(f"Unexpected error: {e}")
return {}
Retry logic
This is the same shape as a decorator-based retry (covered in the decorators guide), written as a plain higher-order function instead — retry_operation takes the function to retry as an argument rather than wrapping it at definition time, which is a reasonable choice when you only need retry behavior at a single call site rather than on every call to a function. The if attempt < max_attempts - 1: ... else: raise branch is what stops this from silently returning None after exhausting all attempts; without it, a caller who doesn’t check the return value would have no way to tell “genuinely succeeded” apart from “failed every attempt but nobody noticed.”
import time
def retry_operation(func, max_attempts=3):
"""Retry func until success or attempts exhausted."""
for attempt in range(max_attempts):
try:
return func()
except Exception as e:
print(f"Attempt {attempt + 1} failed: {e}")
if attempt < max_attempts - 1:
time.sleep(1)
else:
raise
# Usage
def unstable_operation():
import random
if random.random() < 0.7:
raise ConnectionError("connection failed")
return "success"
try:
result = retry_operation(unstable_operation)
print(result)
except Exception as e:
print(f"Final failure: {e}")
Exception-handling pitfalls
Exception-handling best practices
# ✅ Catch specific exceptions
try:
value = int(user_input)
except ValueError:
print("Please enter a number")
# ❌ Bare except Exception (harder to debug)
try:
value = int(user_input)
except Exception:
print("Something went wrong")
# ✅ Use the exception message
try:
file = open('data.txt', 'r')
except FileNotFoundError as e:
print(f"File error: {e}")
# ✅ Prefer context managers over manual close in finally
try:
with open('data.txt', 'r') as file:
pass
finally:
pass # file already closed by with
The with statement is worth calling out specifically because it replaces the manual try/finally cleanup pattern shown earlier in this guide for anything that’s a context manager (files, locks, database connections, network sockets) — __exit__ is guaranteed to run when the with block exits, for any reason, including an exception propagating through it, so there’s no need to remember to check if 'file' in locals() or call .close() yourself. The broader principle: a bare except Exception (or worse, a bare except: with no type at all) doesn’t just catch the error you expected — it also catches typos (NameError from a misspelled variable), logic bugs, and KeyboardInterrupt-adjacent issues you’d actually want to see, silently converting real bugs into a generic “something went wrong” message that makes them much harder to track down later.
Next in the series
Related Articles
- Python environment setup | Install Python on Windows and Mac
- Python File Handling | Read, Write, CSV, JSON, and pathlib
- Python Classes | Object-Oriented Programming (OOP) Explained
- JavaScript Error Handling: try/catch/finally, Custom Errors, Rejected Promises and Retries