Introduction β Why Errors Happen
In Python, when something goes wrong at runtime, it raises an exception β a signal that an error has occurred. If that exception is not caught and handled, Python prints a traceback and the program stops immediately.
There are two fundamental categories of errors:
- Syntax errors β caught before the program runs (e.g., missing colon, mismatched brackets). You fix these in your editor.
- Exceptions (runtime errors) β occur while the program is running. These can be caught and handled with
try/except.
# Without exception handling β program crashes
number = int("abc") # ValueError: invalid literal for int()
print("This line never runs")
Exception handling transforms this hard crash into a controlled response your program can recover from.
The try / except Block
The most basic form wraps risky code in a try block and places your recovery code in the except block:
try:
number = int(input("Enter a number: "))
result = 100 / number
print(f"100 divided by {number} is {result}")
except ValueError:
print("That's not a valid integer.")
except ZeroDivisionError:
print("You can't divide by zero!")
print("Program continues running...")
Catch the most specific exception type you expect. Using a bare except: or except Exception: as your only clause silences bugs you didn't intend to handle. Be precise.
Catching Multiple Exceptions
You can have as many except clauses as you need, and you can also group multiple exception types into one clause with a tuple:
def safe_divide(a, b):
try:
return a / b
except ZeroDivisionError:
print("Error: Cannot divide by zero")
return None
except TypeError:
print("Error: Both arguments must be numbers")
return None
# Group multiple exceptions in one clause
def parse_input(value):
try:
return int(value)
except (ValueError, TypeError) as e:
print(f"Conversion failed: {e}")
return 0
print(safe_divide(10, 2)) # 5.0
print(safe_divide(10, 0)) # Error: Cannot divide by zero β None
print(parse_input("42")) # 42
print(parse_input("hello")) # Conversion failed: ... β 0
The else and finally Clauses
Python's try block supports two additional optional clauses that give you finer control:
| Clause | When it runs | Use case |
|---|---|---|
else | Only when no exception was raised in try | Code that should run only on success |
finally | Always β whether or not an exception occurred | Cleanup: closing files, releasing connections |
def read_and_process(filename):
file = None
try:
file = open(filename, "r")
data = file.read()
except FileNotFoundError:
print(f"File '{filename}' not found.")
except PermissionError:
print(f"No permission to read '{filename}'.")
else:
# Runs only if open() and read() succeeded
print(f"File read successfully: {len(data)} characters")
return data
finally:
# ALWAYS runs β perfect for cleanup
if file:
file.close()
print("File handle closed.")
return None
read_and_process("notes.txt")
Even if your try or except block has a return statement, the finally block still executes before the function returns. This makes it the safest place for cleanup code.
Accessing Exception Information
Use as e in the except clause to capture the exception object. This gives you access to the error message and other attributes:
import traceback
try:
result = 10 / 0
except ZeroDivisionError as e:
print(f"Exception type : {type(e).__name__}")
print(f"Exception message: {e}")
print(f"Args: {e.args}")
# Getting a full traceback as a string
try:
x = int("bad")
except ValueError as e:
tb = traceback.format_exc()
print("Full traceback:")
print(tb)
The Exception Hierarchy
Python's built-in exceptions are organized in an inheritance tree. Catching a parent catches all children. Knowing the hierarchy helps you catch at the right level of specificity:
# Simplified exception hierarchy
# BaseException
# βββ SystemExit (sys.exit())
# βββ KeyboardInterrupt (Ctrl+C)
# βββ Exception β catch here for general errors
# βββ ArithmeticError
# β βββ ZeroDivisionError
# β βββ OverflowError
# βββ LookupError
# β βββ IndexError
# β βββ KeyError
# βββ ValueError
# βββ TypeError
# βββ AttributeError
# βββ NameError
# βββ IOError / OSError
# β βββ FileNotFoundError
# βββ RuntimeError
# Catching a parent catches all children:
try:
my_list = [1, 2, 3]
print(my_list[10]) # IndexError
except LookupError as e:
# Catches both IndexError and KeyError
print(f"Lookup failed: {e}")
BaseException includes SystemExit and KeyboardInterrupt. Catching it would prevent Ctrl+C from stopping your program. Always use Exception as the broadest catch, and only when genuinely needed.
Raising Exceptions with raise
You can deliberately trigger exceptions using the raise statement. This is useful for enforcing constraints, signaling invalid states, or re-raising caught exceptions:
def set_age(age):
if not isinstance(age, int):
raise TypeError(f"Age must be an integer, got {type(age).__name__}")
if age < 0 or age > 150:
raise ValueError(f"Age {age} is not realistic (expected 0β150)")
return age
# Re-raising: catch, log, then let it propagate
def load_config(path):
try:
with open(path) as f:
return f.read()
except FileNotFoundError as e:
print(f"[ERROR] Config file missing: {path}")
raise # Re-raises the same exception
# Chaining exceptions with 'raise ... from ...'
def fetch_user(user_id):
try:
# simulate a database error
raise ConnectionError("DB connection refused")
except ConnectionError as e:
raise RuntimeError("Failed to fetch user") from e
try:
set_age("thirty")
except TypeError as e:
print(e)
try:
set_age(200)
except ValueError as e:
print(e)
Custom Exception Classes
For larger applications you should define your own exception types. This makes error handling code self-documenting and lets callers distinguish between different failure modes precisely.
# Base custom exception for the entire application
class AppError(Exception):
"""Base class for all application errors."""
pass
# Domain-specific exceptions
class ValidationError(AppError):
"""Raised when input data fails validation."""
def __init__(self, field, message):
self.field = field
self.message = message
super().__init__(f"Validation error on '{field}': {message}")
class DatabaseError(AppError):
"""Raised when a database operation fails."""
def __init__(self, operation, detail=""):
self.operation = operation
super().__init__(f"Database {operation} failed. {detail}".strip())
class AuthenticationError(AppError):
"""Raised when authentication fails."""
pass
# Using custom exceptions
def register_user(username, email, password):
if len(username) < 3:
raise ValidationError("username", "Must be at least 3 characters")
if "@" not in email:
raise ValidationError("email", "Must be a valid email address")
if len(password) < 8:
raise ValidationError("password", "Must be at least 8 characters")
print(f"User '{username}' registered successfully!")
try:
register_user("Al", "not-an-email", "pass")
except ValidationError as e:
print(f"[{e.field.upper()}] {e.message}")
except AppError as e:
print(f"Application error: {e}")
Exception Handling with Context Managers
The with statement is Python's preferred way to handle resources that need cleanup. Context managers guarantee the cleanup code runs β even if an exception occurs β without writing finally manually.
from contextlib import contextmanager
# The 'with' statement handles file closing automatically
def count_words(filename):
try:
with open(filename, "r") as f:
content = f.read()
return len(content.split())
except FileNotFoundError:
print(f"File not found: {filename}")
return 0
# Custom context manager for database-like transactions
@contextmanager
def managed_transaction(name):
print(f"[TX] Starting transaction: {name}")
try:
yield
print(f"[TX] Committed: {name}")
except Exception as e:
print(f"[TX] Rolled back: {name} β {e}")
raise
# Using the custom context manager
try:
with managed_transaction("create_user"):
print("Inserting user record...")
raise DatabaseError("INSERT", "Duplicate key")
except DatabaseError as e:
print(f"Caught outside: {e}")
Best Practices for Exception Handling
- Be specific: catch the exact exception type, not a broad parent.
- Don't swallow exceptions silently: always log or re-raise if you can't truly handle the error.
- Use finally for cleanup: close files, connections, and locks.
- Prefer context managers:
with open(...)is safer than manualfile.close(). - Create custom exceptions: makes your API self-documenting and catches precise errors.
- Don't use exceptions for flow control: they are for exceptional conditions, not regular logic.
import logging
logging.basicConfig(level=logging.ERROR)
# β Anti-pattern: silent exception swallowing
def bad_parse(text):
try:
return int(text)
except Exception:
pass # Bug disappears here β never do this!
# β
Good pattern: log and return a meaningful default
def good_parse(text, default=0):
try:
return int(text)
except ValueError:
logging.error("Could not parse '%s' as int, using default %d", text, default)
return default
# β Anti-pattern: catching too broadly
def bad_process(data):
try:
result = complex_operation(data)
except Exception: # Catches everything including bugs in your code!
return None
# β
Good pattern: only catch what you expect
def good_process(data):
try:
result = complex_operation(data)
except (ValueError, KeyError) as e:
logging.warning("Expected error during processing: %s", e)
return None
# Other unexpected exceptions will propagate correctly
Real-World Error Handling Patterns
Here is a realistic example combining everything: a function that fetches JSON from a file with robust error handling at each stage.
import json
import logging
class ConfigLoadError(Exception):
pass
def load_config(filepath):
"""
Load and parse a JSON configuration file.
Returns a dict on success, raises ConfigLoadError on failure.
"""
try:
with open(filepath, "r", encoding="utf-8") as f:
raw = f.read()
except FileNotFoundError:
raise ConfigLoadError(f"Config file not found: {filepath}")
except PermissionError:
raise ConfigLoadError(f"No read permission for: {filepath}")
except OSError as e:
raise ConfigLoadError(f"OS error reading {filepath}: {e}") from e
try:
config = json.loads(raw)
except json.JSONDecodeError as e:
raise ConfigLoadError(
f"Invalid JSON in {filepath} at line {e.lineno}: {e.msg}"
) from e
if not isinstance(config, dict):
raise ConfigLoadError("Config file must contain a JSON object at top level")
logging.info("Config loaded successfully from %s", filepath)
return config
# Caller handles the single, descriptive exception type
try:
cfg = load_config("app_config.json")
print("Database host:", cfg.get("db_host"))
except ConfigLoadError as e:
print(f"Startup failed: {e}")
# In a real app you'd exit(1) here
ποΈ Practical Exercise
Write a function safe_calculator() that:
- Accepts two inputs from the user and an operator (
+,-,*,/). - Converts both inputs to floats (handle
ValueError). - Performs the operation (handle
ZeroDivisionErrorfor/). - Raises a custom
InvalidOperatorErrorif the operator is not one of the four supported. - Uses
finallyto print "Calculation attempt complete." regardless of outcome.
π₯ Challenge Exercise
Build a mini retry decorator. Write a decorator @retry(times=3, delay=1) that calls the decorated function up to times attempts if it raises an exception, waits delay seconds between attempts, and raises the last exception if all attempts fail. Test it with a function that randomly raises ConnectionError.
Interview Questions
- What is the difference between a syntax error and an exception in Python?
- Explain the full
try / except / else / finallyflow. When does each block execute? - Why should you always catch specific exceptions rather than using a bare
except? - What is exception chaining? How do you use
raise ... from ...? - How do you create a custom exception class in Python?
- What is the difference between
BaseExceptionandException? - When would you re-raise an exception with a bare
raise? - How does a context manager (
withstatement) relate to exception handling? - What does the
elseclause of atryblock do? - What is the
finallyblock guaranteed to do even when an exception is raised?
π Summary
try/exceptcatches and handles exceptions, preventing program crashes.- Always catch the most specific exception type possible.
- The
elseclause runs only when no exception occurred intry. - The
finallyclause always runs β use it for cleanup (closing files, connections). - Use
as eto capture the exception object and inspect its message and type. raisetriggers an exception;raise ... from ...chains exceptions.- Custom exception classes (inheriting from
Exception) make your code self-documenting. - Context managers (
with) are the cleanest way to guarantee resource cleanup. - Never silently swallow exceptions β always log or re-raise.
Related Topics
Frequently Asked Questions
Yes. You can add as many except clauses as you need. Python checks them top to bottom and executes only the first matching one. Put more specific exceptions before more general ones β otherwise the general one will match first.
If an exception is raised inside finally, it replaces any exception that was already being propagated. This can hide the original error. Avoid raising exceptions in finally blocks β or use a try/except inside finally to handle them locally.
Python encourages the EAFP style ("Easier to Ask Forgiveness than Permission") β try it and catch failures β rather than LBYL ("Look Before You Leap") β check conditions first. EAFP is often cleaner and avoids race conditions (e.g., checking if a file exists before opening it has a race; just open it and catch FileNotFoundError).
Use the logging module with logging.exception("message") inside an except block. Unlike logging.error(), logging.exception() automatically appends the full traceback to the log entry, which is invaluable for debugging in production.