Python Exceptions โ Complete Exception Handling Guide
A complete, professional guide to Python exceptions โ covering the exception hierarchy, try-except-else-finally, raising and chaining exceptions, custom exception classes, context managers, and industry best practices.
Last Updated
March 2026
Read Time
26 min
Level
Intermediate
What is an Exception in Python?
An exception is an event that occurs during the execution of a program that disrupts the normal flow of instructions. In Python, exceptions are objects โ instances of classes that inherit from the built-in BaseException class โ that are raised when an error condition occurs and can be caught (handled) using try and except blocks. Instead of a program crashing silently or printing a cryptic system error, Python converts every runtime problem into a structured, catchable object with a type, a message, and a traceback.
Python treats errors as first-class citizens of the language. Almost everything that can go wrong โ dividing by zero, accessing a missing dictionary key, opening a file that doesn't exist, calling a method on None โ raises an exception rather than returning an error code. This is often summarised by Python's philosophy of "It's easier to ask forgiveness than permission" (EAFP), which favours attempting an operation and handling the exception if it fails, over checking every precondition in advance ("Look Before You Leap" โ LBYL, more common in C-style languages).
It's important to distinguish exceptions from syntax errors. A SyntaxError is detected by the parser before your program even starts running โ it means the code itself is not valid Python. An exception, by contrast, occurs while a syntactically correct program is running โ for example 10 / 0 is perfectly valid syntax, but it raises a ZeroDivisionError the moment it executes.
When an exception is raised and nothing catches it, Python prints a traceback โ a report showing the exact sequence of function calls (the call stack) that led to the error, the file and line number of each call, and the final exception type and message. Reading tracebacks correctly, from the bottom up, is one of the most important debugging skills for any Python developer.
The Python Exception Hierarchy
Every exception in Python is a class, and every exception class ultimately inherits from BaseException โ the root of the entire exception tree. Understanding this hierarchy is essential because it determines what an except clause will and won't catch, and it explains why certain exceptions (like KeyboardInterrupt) should almost never be silently swallowed.
- โถ
BaseException โ The root of all exceptions. Almost never caught directly in application code; doing so would also catch system-exiting signals, which is rarely the intent.
- โถ
SystemExit โ Raised by the built-in <code>sys.exit()</code> function to terminate the interpreter. Direct child of BaseException, not Exception, so a bare <code>except Exception</code> will not accidentally catch it.
- โถ
KeyboardInterrupt โ Raised when the user presses Ctrl+C. Also a direct child of BaseException for the same reason โ you don't want a broad except clause to block a user's shutdown request.
- โถ
GeneratorExit โ Raised inside a generator or coroutine when its <code>close()</code> method is called, signalling that iteration should stop.
- โถ
Exception โ The base class for essentially all errors that application code is expected to catch and handle. This is the class you should subclass when creating your own custom exceptions.
- โถ
ArithmeticError โ Base class for numeric errors, with children ZeroDivisionError, OverflowError, and FloatingPointError.
- โถ
LookupError โ Base class for indexing/key errors, with children IndexError (sequence index out of range) and KeyError (dictionary key not found).
- โถ
ValueError โ Raised when an operation receives an argument of the right type but an inappropriate value, e.g. int('abc').
- โถ
TypeError โ Raised when an operation is applied to an object of an inappropriate type, e.g. 'a' + 5.
- โถ
OSError โ Base class for system-related errors such as file and network issues; FileNotFoundError, PermissionError, and ConnectionError all inherit from it.
- โถ
AttributeError โ Raised when an attribute reference or assignment fails, e.g. calling a method that doesn't exist on an object.
- โถ
ImportError / ModuleNotFoundError โ Raised when an import statement fails to find or load a module or a name within a module.
- โถ
StopIteration โ Raised internally by iterators to signal that there are no more items; consumed automatically by for-loops.
- โถ
RuntimeError โ A generic catch-all raised when an error doesn't fit any more specific category; RecursionError (stack depth exceeded) is a subclass.
The single most important rule that follows from this hierarchy: always catch the most specific exception class possible. Writing a bare except: or an overly broad except Exception: hides bugs, makes debugging harder, and โ in the case of a truly bare except: โ can accidentally swallow KeyboardInterrupt and SystemExit, making your program impossible to stop cleanly.
Common Built-in Exceptions โ Reference Table
Python ships with dozens of built-in exception classes covering nearly every category of runtime failure. This table lists the ones you will encounter most often, along with the situation that typically triggers each one.
The try-except Block โ Basic Exception Handling
The fundamental tool for handling exceptions in Python is the try/except block. Code that might raise an exception is placed inside the try block; code that should run only if a specific exception occurs is placed inside the matching except block. If no exception occurs, every except block is skipped entirely.
Basic Syntax
try:
number = int(input("Enter a number: "))
result = 100 / number
except ZeroDivisionError:
print("You cannot divide by zero.")
except ValueError:
print("That was not a valid number.")
else:
print(f"100 divided by {number} is {result}")
finally:
print("Execution of the try block has finished.")Output
Enter a number: 0 You cannot divide by zero. Execution of the try block has finished.Catching Multiple Exceptions in One Clause
When two or more exception types should trigger identical handling logic, they can be grouped into a single except clause using a tuple. This avoids duplicating the same handler code for each exception type.
try:
data = {"age": "twenty"}
value = int(data["age"])
except (ValueError, KeyError) as error:
print(f"Input problem: {error}")Output
Input problem: invalid literal for int() with base 10: 'twenty'The four clauses explained
- โถ
tryโ Contains the code that might raise an exception. Python executes this block first, watching for any errors. - โถ
exceptโ Runs only if the specified exception type (or one of its subclasses) is raised inside the try block. You can have multiple except clauses for different exception types. - โถ
elseโ Runs only if the try block completed with no exception at all. Useful for code that should run only on success, keeping it separate from the risky code in try. - โถ
finallyโ Always runs, whether an exception occurred or not, and even if the exception was not caught. Ideal for cleanup code like closing files or releasing locks.
How Python Exception Handling Works โ Flowchart
The diagram below traces exactly what the Python interpreter does when it hits a try block โ how it decides which except clause matches, when else runs, and why finally always executes regardless of the outcome.
Code Execution Flow โ from source to output
Key insight: the finally block always runs โ even if the exception is unhandled, even if there is a return statement inside try or except, and even if a new exception is raised inside the except block itself. The only way to skip finally entirely is if the interpreter process itself crashes (e.g. os._exit() or a fatal signal).
Raising Exceptions with the raise Statement
So far we've only handled exceptions raised automatically by Python. You can also raise exceptions deliberately using the raise keyword โ useful for enforcing business rules, validating function arguments, or signalling that a precondition wasn't met.
def withdraw(balance, amount):
if amount <= 0:
raise ValueError("Withdrawal amount must be positive.")
if amount > balance:
raise RuntimeError("Insufficient funds for this withdrawal.")
return balance - amount
try:
new_balance = withdraw(100, 250)
except ValueError as ve:
print(f"Invalid input: {ve}")
except RuntimeError as re:
print(f"Transaction failed: {re}")Output
Transaction failed: Insufficient funds for this withdrawal.Re-raising an Exception
Inside an except block, calling raise with no arguments re-raises the exception currently being handled, preserving its original traceback. This is a common pattern when you want to log an error before letting it propagate further up the call stack.
import logging
def process_order(order_id):
try:
risky_database_call(order_id)
except ConnectionError:
logging.error(f"DB connection failed for order {order_id}")
raise # re-raises the original ConnectionErrorException Chaining with raise ... from ...
When you catch one exception and raise a different, more meaningful one in response, Python lets you explicitly link the two using raise NewException(...) from original_exception. This preserves both tracebacks โ the new exception shows "The above exception was the direct cause of the following exception" โ which is invaluable for debugging layered systems.
class OrderProcessingError(Exception):
pass
def load_order(order_id):
try:
return database.fetch(order_id)
except KeyError as exc:
raise OrderProcessingError(
f"Order {order_id} could not be found."
) from excIf you want to suppress the original context entirely โ for example when the underlying cause is irrelevant noise โ you can write raise NewException(...) from None, which hides the chained traceback from the output.
Creating Custom Exception Classes
Real-world applications almost always define their own exception classes rather than relying solely on built-ins. Custom exceptions make error handling code self-documenting โ catching InsufficientStockError is far clearer than catching a generic ValueError and inspecting its message string.
class ApplicationError(Exception):
"""Base class for all custom exceptions in this application."""
pass
class InsufficientStockError(ApplicationError):
"""Raised when an order requests more stock than is available."""
def __init__(self, product, requested, available):
self.product = product
self.requested = requested
self.available = available
message = (
f"Cannot fulfil order for '{product}': "
f"requested {requested}, only {available} in stock."
)
super().__init__(message)
def place_order(product, requested, available):
if requested > available:
raise InsufficientStockError(product, requested, available)
return "Order placed successfully."
try:
place_order("Wireless Mouse", 15, 4)
except InsufficientStockError as e:
print(e)
print(f"Shortfall: {e.requested - e.available} units")Output
Cannot fulfil order for 'Wireless Mouse': requested 15, only 4 in stock. Shortfall: 11 unitsWhy Build an Exception Hierarchy of Your Own?
- โถ
Single base class โ Defining one root exception (e.g. ApplicationError) for your whole application lets callers catch "anything my code might raise" with one except clause, without swallowing unrelated system exceptions.
- โถ
Carries structured data โ Unlike a plain string message, a custom exception can carry attributes (product, requested, available in the example above) that calling code can inspect programmatically.
- โถ
Self-documenting โ Exception names like PaymentDeclinedError or TokenExpiredError communicate intent instantly, both to other developers and in log files.
- โถ
Enables precise handling โ Different failure modes can be handled differently by callers, rather than parsing an error message string to figure out what went wrong.
The with Statement and Context Managers
A very common source of bugs is forgetting to release a resource โ closing a file, releasing a lock, or closing a network connection โ especially when an exception causes a function to exit early. Python's with statement (backed by the context manager protocol, i.e. objects implementing __enter__ and __exit__) guarantees that cleanup happens automatically, even if an exception is raised inside the block.
# Without a context manager โ error-prone
file = open("data.txt", "r")
try:
contents = file.read()
finally:
file.close()
# With a context manager โ safer and shorter
with open("data.txt", "r") as file:
contents = file.read()
# file.close() is called automatically, even if read() raises an exceptionYou can build your own context managers either by implementing a class with __enter__/__exit__, or more concisely using the @contextmanager decorator from the built-in contextlib module, wrapping a generator function with a single yield statement.
EAFP vs LBYL โ Two Philosophies of Error Handling
Python code can validate preconditions before acting (LBYL) or attempt the action and handle failure afterward (EAFP). This table compares both styles directly on the same problem.
Python's standard library and most idiomatic Python code favour EAFP โ it results in code that reads cleanly along the successful path, and it avoids a whole class of race-condition bugs where a condition is checked but becomes false by the time the action is attempted.
Exception Propagation Architecture
When an exception is raised deep inside a call stack, it doesn't just vanish โ it propagates upward through every enclosing function until something catches it, or it reaches the top level and crashes the program. The diagram below shows this propagation path across layers of a typical application.
Each function frame that the exception passes through, without being caught, adds one entry to the traceback โ this is why a traceback reads like a stack of breadcrumbs from the outermost call down to the exact line where the exception originated.
Practice: Robust File Reading with Exception Handling
Here is a realistic, production-style example that combines everything covered so far โ specific exception types, a custom exception, a with statement, and a finally block โ to read and parse a configuration file safely.
import json
class ConfigError(Exception):
"""Raised when the application configuration cannot be loaded."""
pass
def load_config(path):
try:
with open(path, "r") as file:
return json.load(file)
except FileNotFoundError as exc:
raise ConfigError(f"Config file not found: {path}") from exc
except json.JSONDecodeError as exc:
raise ConfigError(f"Config file is not valid JSON: {exc}") from exc
except PermissionError as exc:
raise ConfigError(f"No permission to read: {path}") from exc
try:
config = load_config("settings.json")
except ConfigError as e:
print(f"Startup aborted โ {e}")
else:
print("Configuration loaded successfully.")
finally:
print("Config loading attempt finished.")Output
Startup aborted โ Config file not found: settings.json Config loading attempt finished.Practice This Code โ Live Editor
Exception Handling Best Practices
Writing exception handling code that is genuinely production-ready requires more discipline than simply wrapping code in try/except. These practices separate professional error handling from code that merely appears to handle errors.
- โถ
Catch specific exceptions, never bare except โ A bare except: catches everything, including KeyboardInterrupt and SystemExit, making your program impossible to interrupt cleanly and hiding real bugs.
- โถ
Keep the try block small โ Only wrap the exact line(s) that can actually raise the exception you're catching. A large try block makes it unclear which statement actually failed.
- โถ
Never use exceptions for normal control flow โ Exceptions should represent exceptional conditions, not everyday logic branches; overusing them for flow control hurts both performance and readability.
- โถ
Always log before swallowing โ If you must catch and suppress an exception, log it first (traceback included) so the failure isn't silently lost during debugging.
- โถ
Use finally or with for cleanup โ Any resource acquired (file handles, network sockets, database connections, locks) should be released in a finally block or, preferably, via a context manager.
- โถ
Chain exceptions with raise ... from ... โ When translating a low-level exception into a higher-level one, always chain it so the original cause remains visible in the traceback.
- โถ
Build a small custom exception hierarchy โ Define one base application exception and specific subclasses for distinct failure modes, rather than reusing generic ValueError/RuntimeError everywhere.
- โถ
Don't return None to signal failure silently โ Returning None from a function that failed forces every caller to remember to check for it; raising a specific exception makes failures impossible to ignore accidentally.
- โถ
Avoid catching exceptions you can't meaningfully handle โ If your except block does nothing but re-raise, consider whether that layer needs a try/except at all.
- โถ
Test the failure paths, not just the happy path โ Unit tests should explicitly assert that the correct exception type is raised under bad input, using tools like pytest.raises().
Advantages and Disadvantages of Python's Exception Model
Python's exception handling design is powerful and expressive, but like any tool it has trade-offs worth understanding before leaning on it heavily.
Python Exceptions โ Interview Questions
These are the most frequently asked interview questions on Python exception handling, ranging from fundamentals to trickier edge cases interviewers use to test deeper understanding.
Practice Questions โ Test Your Understanding
Work through these practice questions to test your understanding of Python exception handling. Try to reason out each answer before revealing it.
1. What will this code print? try: print(1/0) except ZeroDivisionError: print('A') except Exception: print('B') finally: print('C')
Easy2. Will 'finally' run if the try block contains a return statement?
Medium3. What is the output of: except (TypeError, ValueError) as e: print(type(e)) when a ValueError is raised?
Easy4. Why is catching Exception generally safer than a bare except:, but still discouraged as a habit?
Medium5. What does 'raise' with no arguments do when used inside an except block, versus outside one?
Hard6. What is the difference between raise ValueError('bad input') from exc and raise ValueError('bad input') from None?
Hard7. If a custom exception class overrides __init__ but forgets to call super().__init__(message), what breaks?
Medium8. Why does using exceptions for routine control flow hurt performance in Python compared to a simple if-check?
HardConclusion โ Mastering Python Exception Handling
Exception handling is not an optional add-on to Python programming โ it is a core part of how the language expresses success and failure. Every reliable Python program, from a small script to a large-scale production service, depends on precise, intentional use of try, except, else, and finally to behave predictably when something goes wrong.
The path to mastery follows a clear progression: start by understanding the exception hierarchy so you know exactly what each except clause catches. Learn to raise exceptions deliberately to enforce your own business rules. Build a small, well-designed set of custom exception classes so failures in your application are self-documenting. Use context managers for guaranteed cleanup instead of manual finally blocks wherever possible. And above all, resist the temptation to reach for a bare except: โ specificity is what makes error handling code trustworthy rather than merely present.
The natural next step in your Python journey is to explore related topics that build directly on exception handling: context managers and the contextlib module, logging (Python's built-in logging module for structured error reporting), and unit testing failure paths using tools like pytest's pytest.raises(). Together, these form the foundation of writing Python code that fails safely, loudly, and informatively โ exactly when it should.