Errors are inevitable in software. Files don't exist, network connections fail, users enter unexpected input, and APIs return unexpected responses. Error handling is how a program responds to these situations gracefully — giving useful feedback, recovering where possible, and failing clearly when not. Python's error handling system is clean, explicit, and built around exceptions.
Errors vs. Exceptions
Python distinguishes between two types of problems:
Syntax errors occur when Python cannot parse your code — the code is not valid Python. These are caught before the program runs:
if True
print("missing colon") # SyntaxError: expected ':'
Exceptions occur during execution — the code is valid Python, but something goes wrong at runtime:
print(10 / 0) # ZeroDivisionError
print(int("hello")) # ValueError
print(name) # NameError: name 'name' is not defined
Error handling deals with exceptions — syntax errors must be fixed in the code before the program can run.
The try / except Block
The fundamental error handling structure in Python:
try:
result = 10 / 0
except ZeroDivisionError:
print("Cannot divide by zero.")
Python attempts to execute the try block. If an exception occurs, execution jumps immediately to the matching except block. If no exception occurs, the except block is skipped entirely.
Catching multiple exceptions
try:
value = int(input("Enter a number: "))
result = 100 / value
print(result)
except ValueError:
print("That's not a valid number.")
except ZeroDivisionError:
print("Cannot divide by zero.")
Python checks each except clause in order and executes the first one that matches the raised exception.
Catching multiple exceptions in one clause
try:
value = int(input("Enter a number: "))
except (ValueError, TypeError):
print("Invalid input.")
Catching any exception
try:
risky_operation()
except Exception as e:
print(f"An error occurred: {e}")
Exception is the base class for all non-system-exiting exceptions. Catching it handles everything but SystemExit, KeyboardInterrupt, and GeneratorExit. Avoid catching bare except: without specifying a type — it catches everything including KeyboardInterrupt (Ctrl+C), making the program impossible to interrupt.
Accessing the exception object
try:
result = 10 / 0
except ZeroDivisionError as e:
print(f"Error: {e}") # Error: division by zero
print(type(e).__name__) # ZeroDivisionError
The as e syntax binds the exception instance to a variable so you can inspect its message and type.
else and finally
The full try block structure has four clauses:
try:
# Code that might raise an exception
except SomeException:
# Runs if that exception occurs
else:
# Runs only if NO exception occurred
finally:
# Always runs — exception or not
else
The else block runs only when the try block completes without raising an exception. It is the place for code that should only execute if everything went smoothly:
try:
file = open("data.txt", "r")
except FileNotFoundError:
print("File not found.")
else:
content = file.read()
print(content)
file.close()
Using else makes it clear that content = file.read() should not run if the file wasn't opened successfully.
finally
The finally block always runs — whether an exception occurred or not, whether it was caught or not. It is used for cleanup operations that must happen regardless:
file = None
try:
file = open("data.txt", "r")
content = file.read()
except FileNotFoundError:
print("File not found.")
finally:
if file:
file.close() # Always close the file, even if an error occurred
print("Done.")
In practice, with statements handle cleanup more cleanly than finally for resources like files. finally is most useful for network connections, database sessions, or lock releases — resources without a context manager.
Common Built-in Exceptions
Python has a rich hierarchy of built-in exceptions. The most frequently encountered:
| Exception | When it occurs |
|---|---|
ValueError | Function receives the right type but an invalid value — int("hello") |
TypeError | Operation applied to the wrong type — "5" + 3 |
NameError | Variable or function name not defined |
IndexError | List index out of range — [1, 2, 3][5] |
KeyError | Dictionary key does not exist — d["missing"] |
AttributeError | Attribute or method does not exist on an object |
FileNotFoundError | File or directory does not exist |
PermissionError | Insufficient permissions |
ZeroDivisionError | Division or modulo by zero |
OverflowError | Result too large to represent |
ImportError | Module cannot be found or imported |
ModuleNotFoundError | Subclass of ImportError — module does not exist |
StopIteration | Iterator has no more items |
RuntimeError | Generic error that doesn't fit other categories |
NotImplementedError | Abstract method has not been implemented |
OSError | Operating system error — base class for file and I/O errors |
The exception hierarchy
BaseException
├── SystemExit
├── KeyboardInterrupt
├── GeneratorExit
└── Exception
├── ValueError
├── TypeError
├── NameError
├── IndexError
├── KeyError
├── AttributeError
├── OSError
│ ├── FileNotFoundError
│ ├── PermissionError
│ └── ...
└── ...
Catching a parent class catches all its subclasses. Catching OSError will also catch FileNotFoundError and PermissionError.
Raising Exceptions — raise
You can raise exceptions deliberately in your own code to signal that something has gone wrong:
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 must be between 0 and 150, got {age}")
return age
set_age("twenty") # TypeError: Age must be an integer, got str
set_age(200) # ValueError: Age must be between 0 and 150, got 200
Raising exceptions from your own code makes validation explicit and gives callers clear, descriptive error messages.
Re-raising an exception
Sometimes you want to catch an exception, do something (like log it), and then let it propagate:
try:
risky_operation()
except ValueError as e:
log_error(e) # Log it
raise # Re-raise the same exception
Using bare raise (without arguments) re-raises the currently active exception, preserving the original traceback.
Custom Exceptions
For application-specific errors, define your own exception classes by inheriting from Exception or an appropriate subclass:
class InsufficientFundsError(Exception):
def __init__(self, amount, balance):
self.amount = amount
self.balance = balance
super().__init__(
f"Cannot withdraw {amount}. Balance is {balance}."
)
class BankAccount:
def __init__(self, balance):
self.balance = balance
def withdraw(self, amount):
if amount > self.balance:
raise InsufficientFundsError(amount, self.balance)
self.balance -= amount
return self.balance
account = BankAccount(100)
try:
account.withdraw(150)
except InsufficientFundsError as e:
print(e) # Cannot withdraw 150. Balance is 100.
print(e.amount) # 150
print(e.balance) # 100
Custom exceptions make your error handling more expressive — callers can catch your specific exception type rather than a generic one, and the exception object can carry additional information.
Exception Chaining
When handling one exception causes another, Python can link them:
try:
value = int("invalid")
except ValueError as e:
raise RuntimeError("Failed to process input") from e
The from e syntax creates an explicit exception chain. When this RuntimeError is displayed, Python shows both the original ValueError and the new RuntimeError, making the full chain of events visible in the traceback.
Context Managers and __exit__
The with statement relies on the context manager protocol. When an exception occurs inside a with block, the context manager's __exit__ method is called with the exception information — allowing it to suppress or handle the exception:
class ManagedFile:
def __init__(self, name):
self.name = name
def __enter__(self):
self.file = open(self.name, "r")
return self.file
def __exit__(self, exc_type, exc_val, exc_tb):
self.file.close()
return False # False means don't suppress the exception
with ManagedFile("data.txt") as f:
content = f.read()
Returning True from __exit__ suppresses the exception. Returning False (or None) allows it to propagate. This is covered in more depth in the Python-Specific Features topic.
Best Practices
Be specific. Catch the most specific exception you can. Catching Exception everywhere hides bugs and makes debugging harder.
# Too broad — catches everything, hides problems
except Exception:
pass
# Specific — clear intent
except FileNotFoundError:
print("Config file missing — using defaults.")
Never silently suppress exceptions. A bare except: pass that swallows errors is one of the most dangerous patterns in Python:
# ❌ Never do this — errors disappear silently
try:
do_something()
except:
pass
Keep try blocks small. Only wrap the specific statement that might fail — not large blocks of code. This makes it clear exactly what you are guarding against:
# ❌ Too much in the try block
try:
data = fetch_data()
processed = process(data)
save(processed)
except Exception:
pass
# ✅ Specific
try:
data = fetch_data()
except NetworkError as e:
log(e)
data = get_cached_data()
processed = process(data)
save(processed)
Use finally for cleanup, with when available. The with statement is cleaner for resources that support it. Reserve finally for cases where no context manager exists.
Provide useful error messages. Whether raising built-in or custom exceptions, the message should tell the caller what went wrong and ideally what the valid values are.
Summary
| Statement | Purpose |
|---|---|
try: ... except E: | Catch a specific exception |
except (E1, E2): | Catch multiple exception types |
except E as e: | Access the exception object |
except Exception: | Catch all non-system exceptions |
else: | Runs only if no exception occurred |
finally: | Always runs — used for cleanup |
raise ExceptionType("msg") | Raise an exception deliberately |
raise | Re-raise the current exception |
raise E from original | Chain exceptions |
class MyError(Exception): | Define a custom exception |