Python
What Happens When Python Catches an Exception? (And What Happens When It Doesn't)
A deep look at Python's exception handling internals: stack unwinding, matching rules, else/finally clauses, bare except dangers, custom exceptions, performance costs, and production-ready patterns for predictable code.
June 2026 · 8 min read · 1 views · 0 hearts
Advertisement
What Happens When Python Catches an Exception? (And What Happens When It Doesn't)
You're hunched over your keyboard at 2 AM. The deployment is going out in the morning. You write try:, cross your fingers, and throw a blanket except Exception: pass over everything like a tarp over a leaky roof.
We've all done it. But here's the thing: Python's exception handling is one of its most elegant features—when you use it right. Understanding what actually happens under the hood can save you from bugs that only surface in production at 3 AM on a Sunday.
The Stack Unwinds
When an exception occurs, Python doesn't just stop. It begins a systematic search up the call stack. Think of it like a game of telephone gone wrong—each function frame gets a chance to handle the message before it reaches the top.
def deep_function():
raise ValueError("Something went wrong deep down")
def middle_function():
deep_function() # This call is about to explode
def top_function():
try:
middle_function()
except ValueError as e:
print(f"Caught it: {e}")
top_function()
Here's the play-by-play:
1. deep_function() raises a ValueError
2. Python checks if deep_function() has a matching except block—nope
3. The stack unwinds to middle_function()—still no handler
4. Finally reaches top_function() where except ValueError catches it
5. The stack stops unwinding, and execution continues after the try/except
The Exception Matching Rules
Python doesn't just check for exact type matches. It uses inheritance, which is both powerful and occasionally surprising.
try:
raise ValueError("nope")
except Exception:
print("Catches everything!")
This catches ValueError because ValueError inherits from Exception. And since almost everything inherits from Exception, this effectively catches most errors you'll encounter.
The order of your except blocks matters enormously:
try:
brokencode()
except TypeError:
print("Type error")
except Exception:
print("Generic fallback")
Python tries each except block in order, from top to bottom. The first matching block wins. Put your most specific exceptions first, your generic fallback last.
The Else and Finally Clauses
Two underused clauses deserve more attention:
else runs only if no exception occurred in the try block. Perfect for code that should execute on success but might raise its own exceptions:
try:
data = fetch_from_network()
except ConnectionError:
data = get_cached_data()
else:
# Only runs if fetch succeeded
cache_the_data(data) # This can raise its own exceptions
finally always runs—whether the try block completed, an exception was caught, or the exception propagated. This is critical for cleanup:
file = open("data.txt")
try:
process(file.read())
except ProcessingError:
log("Processing failed")
finally:
file.close() # This runs no matter what
The Silent Killer: Bare except
This is the anti-pattern that haunts production code:
try:
do_something()
except: # Bare except catches everything, including KeyboardInterrupt and SystemExit
pass
Bare except: catches KeyboardInterrupt (Ctrl+C) and SystemExit. That means if you accidentally write an infinite loop and try to kill it, your except: block will swallow the shutdown request. Your program becomes unkillable short of kill -9.
Always specify the exception type, or at minimum use except Exception:.
Raising Exceptions Properly
When you catch an exception and need to re-raise, do it right:
# Bad - loses original traceback
try:
do_stuff()
except ValueError:
raise RuntimeError("Something went wrong")
# Good - preserves chain
try:
do_stuff()
except ValueError as e:
raise RuntimeError("Something went wrong") from e
The from keyword creates an exception chain. When you print the traceback, Python shows both exceptions, making debugging much easier.
Custom Exceptions Matter
Python's built-in exceptions cover most cases, but custom exceptions make your code self-documenting:
class DatabaseConnectionError(Exception):
"""Raised when we can't connect to the database"""
pass
class UserNotFoundError(Exception):
"""Raised when a user ID doesn't exist"""
pass
These aren't just documentation—they let callers catch exactly what they need:
try:
user = fetch_user(user_id)
except UserNotFoundError:
create_user(user_id)
except DatabaseConnectionError:
retry_later(user_id)
The Performance Cost
Exception handling isn't free. The try block itself has almost zero overhead when no exception occurs. But raising an exception is expensive—it involves building a traceback object, unwinding the stack, and potentially formatting error messages.
Don't use exceptions for control flow:
# Slow - don't do this
try:
result = dict[key]
except KeyError:
result = default
# Fast - do this instead
result = dict.get(key, default)
The second version is about 10x faster when the key is missing, and slightly faster when it's present.
Context Managers Are Your Friend
For resource management, with statements handle exceptions more cleanly than manual try/finally:
# Manual way
file = None
try:
file = open("data.txt")
process(file)
finally:
if file:
file.close()
# Clean way
with open("data.txt") as file:
process(file) # File closes automatically, even on exception
Python's context manager protocol calls __enter__ and __exit__ methods. The __exit__ method receives any exception that occurred, letting context managers suppress exceptions or perform cleanup.
The One Pattern You Should Memorize
When you're writing production code, this pattern handles 90% of your real-world needs:
import logging
logger = logging.getLogger(__name__)
def fetch_user_data(user_id):
try:
response = api_call(user_id)
return response.json()
except ConnectionError as e:
logger.error(f"Network error fetching user {user_id}: {e}")
raise
except ValueError as e:
logger.error(f"Invalid JSON for user {user_id}: {e}")
return None
except Exception as e:
logger.critical(f"Unexpected error for user {user_id}: {e}")
raise
else:
logger.info(f"Successfully fetched user {user_id}")
This pattern:
- Logs errors with context
- Re-raises critical failures
- Returns None for recoverable errors
- Logs success separately
- Never catches KeyboardInterrupt or SystemExit
Exception handling in Python isn't just about preventing crashes. It's about making your code predictable, debuggable, and honest about what can go wrong. The best error handling is the kind you write before the 2 AM deployment, not after.
Advertisement
Comments
Questions, corrections, and tips stay visible for everyone reading this page.
Join the discussion
No comments yet
Be the first to leave a note — it helps the next reader.