Python Error Handling — Exceptions, Logging & Best Practices
Error handling in Python is the practice of anticipating, catching, and responding to runtime errors (exceptions) so your program doesn’t crash unexpectedly. The try/except block is your primary tool.
What You’ll Learn
try/except/else/finally— the complete error-handling structure- Creating custom exception classes
- Exception chaining and
raise from - Context managers for automatic cleanup
assertfor development-time checks- Logging best practices with
logging
Why Error Handling Matters
A crash in production is expensive. Durga Antivirus Pro handles hundreds of file types — if a corrupt file is scanned, error handling catches the IOError, logs it, and moves to the next file instead of crashing. DodaZIP encounters password-protected archives, truncated files, and permission errors daily. Without robust error handling, users see tracebacks instead of helpful messages.
flowchart LR
A["Functions"] --> B["File I/O"]
B --> C["Error Handling"]
C --> D["Testing"]
D --> E["Type Hints"]
E --> F["Async"]
A:::done --> B:::done --> C:::current
style A fill:#2563eb,stroke:#2563eb,color:#fff
style B fill:#2563eb,stroke:#2563eb,color:#fff
style C fill:#2563eb,stroke:#2563eb,color:#fff
style D fill:#dbeafe,stroke:#2563eb,color:#1e40af
style E fill:#dbeafe,stroke:#2563eb,color:#1e40af
style F fill:#f1f5f9,stroke:#94a3b8,color:#64748b
The try/except/else/finally Structure
try:
file = open("config.yaml", "r")
data = file.read()
except FileNotFoundError:
print("Config file not found. Using defaults.")
except PermissionError:
print("Permission denied. Check file permissions.")
else:
print(f"Loaded {len(data)} bytes successfully.")
finally:
file.close()| Block | When It Runs |
|---|---|
try | Always — the code to monitor |
except | Only if an exception is raised |
else | Only if NO exception was raised |
finally | Always — cleanup code |
Catching Multiple Exceptions
try:
result = risky_operation()
except (ValueError, TypeError) as e:
print(f"Input error: {e}")
except Exception as e:
print(f"Unexpected error: {e}")
else:
print(f"Success: {result}")Catch the most specific exceptions first, Exception (or a broad type) last. Never use bare except: — it catches KeyboardInterrupt and SystemExit too.
Custom Exceptions
Create domain-specific exceptions by subclassing Exception:
class InsufficientFundsError(Exception):
"""Raised when a withdrawal exceeds the account balance."""
def __init__(self, balance: float, amount: float):
self.balance = balance
self.amount = amount
super().__init__(f"Insufficient funds: ${amount} requested, ${balance} available")
class BankAccount:
def __init__(self, balance: float = 0):
self._balance = balance
def withdraw(self, amount: float) -> float:
if amount > self._balance:
raise InsufficientFundsError(self._balance, amount)
self._balance -= amount
return amount
try:
account = BankAccount(100)
account.withdraw(200)
except InsufficientFundsError as e:
print(e) # Insufficient funds: $200 requested, $100 availableException Chaining with raise ... from
When one exception triggers another, chain them to preserve the full traceback:
def parse_config(path: str) -> dict:
try:
with open(path) as f:
return yaml.safe_load(f)
except FileNotFoundError as e:
raise RuntimeError(f"Cannot start: config file {path} missing") from eThe from e clause chains the original FileNotFoundError as the cause of the new RuntimeError. Without it, you lose the original error context.
Context Managers with with
The with statement guarantees cleanup even if an exception occurs:
# Manual cleanup — error-prone
f = open("file.txt")
try:
data = f.read()
finally:
f.close()
# Context manager — automatic
with open("file.txt") as f:
data = f.read()You can create your own context managers with __enter__ and __exit__ (see https://tutorials.dodatech.com/programming-languages/python/py-context-managers/).
assert for Development Checks
assert raises AssertionError if the condition is False. Use it for invariant checks, not user input validation:
def divide(a: float, b: float) -> float:
assert b != 0, "Division by zero!"
return a / b
divide(10, 0) # AssertionError: Division by zero!assert statements are removed when Python runs in optimized mode (python -O). Never use assert for validation that must always run — use if + raise instead.Logging Best Practices
Use the logging module instead of print() for production code:
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
filename="app.log",
filemode="a",
)
logger = logging.getLogger(__name__)
def process_file(path: str) -> None:
logger.info(f"Processing {path}")
try:
with open(path) as f:
data = f.read()
logger.debug(f"Read {len(data)} bytes")
except FileNotFoundError:
logger.error(f"File not found: {path}")
except PermissionError:
logger.error(f"Permission denied: {path}")
else:
logger.info(f"Successfully processed {path}")
process_file("test.txt")| Level | When to Use |
|---|---|
DEBUG | Detailed diagnostic info |
INFO | Normal operations |
WARNING | Something unexpected but not critical |
ERROR | Function couldn’t complete |
CRITICAL | Program can’t continue |
Real-World Example: File I/O with Error Handling
import logging
from typing import Optional
logger = logging.getLogger(__name__)
class ConfigLoadError(Exception):
"""Configuration loading failed."""
def load_config(path: str) -> Optional[dict]:
"""Load a YAML config file with comprehensive error handling."""
try:
with open(path, "r") as f:
content = f.read()
except FileNotFoundError:
logger.warning(f"Config {path} not found, using defaults")
return None
except PermissionError:
logger.error(f"Cannot read {path} — permission denied")
raise ConfigLoadError(f"Permission denied: {path}")
except IOError as e:
logger.error(f"IO error reading {path}: {e}")
raise ConfigLoadError(f"IO error: {e}") from e
else:
logger.info(f"Loaded config from {path} ({len(content)} bytes)")
return {"raw": content, "path": path}Common Mistakes
1. Bare except: Clauses
try:
result = do_something()
except: # Catches everything, including keyboard interrupts!
passFix: Always specify exception types. Use except Exception as e if you must catch broadly.
2. Swallowing Exceptions Silently
try:
risky_call()
except Exception:
pass # Error is hidden — bad!Fix: At minimum log the exception with logger.exception().
3. Not Using finally for Cleanup
If you open a resource manually (no with), always use finally to close it.
4. Raising Exception Directly
raise Exception("Something went wrong") # Too genericFix: Create or use specific exception types (ValueError, TypeError, or a custom one).
5. Using assert for Input Validation
def get_user(uid: int):
assert uid > 0 # Removed with python -O!
# ...Fix: Use if uid <= 0: raise ValueError("uid must be positive").
Practice Questions
1. What runs in try/except/else/finally when no exception occurs?try → else → finally.
2. Why should you avoid bare except:?
It catches KeyboardInterrupt, SystemExit, and GeneratorExit — things you usually don’t want to catch.
3. What’s the difference between raise and raise ... from e?raise alone re-raises the current exception. raise ... from e chains the new exception to the original cause.
4. When is assert removed from execution?
When Python runs with the -O (optimize) flag.
Challenge: Write a function safe_divide(a, b) that returns the result or one of the custom exceptions DivisionByZeroError or TypeMismatchError.
Solution
class DivisionByZeroError(Exception): pass
class TypeMismatchError(Exception): pass
def safe_divide(a, b):
if not isinstance(a, (int, float)) or not isinstance(b, (int, float)):
raise TypeMismatchError(f"Expected numbers, got {type(a).__name__}, {type(b).__name__}")
if b == 0:
raise DivisionByZeroError("Cannot divide by zero")
return a / bMini Project: Robust File Processor
import logging
from pathlib import Path
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)
def process_files(directory: str, pattern: str = "*.txt") -> None:
"""Process all matching files in a directory with error handling."""
path = Path(directory)
for file_path in path.glob(pattern):
try:
with open(file_path, "r") as f:
content = f.read()
word_count = len(content.split())
logger.info(f"{file_path.name}: {word_count} words")
except FileNotFoundError:
logger.error(f"{file_path.name}: File disappeared before reading")
except PermissionError:
logger.error(f"{file_path.name}: Permission denied")
except Exception as e:
logger.exception(f"{file_path.name}: Unexpected error: {e}")
# Create test files
Path("/tmp/test_files").mkdir(exist_ok=True)
for f in ["hello.txt", "data.txt"]:
Path(f"/tmp/test_files/{f}").write_text("hello world " * 10)
process_files("/tmp/test_files")Expected output:
2026-06-15 10:00:00 [INFO] hello.txt: 20 words
2026-06-15 10:00:00 [INFO] data.txt: 20 wordsWhat’s Next
Error handling and testing go hand in hand. Next, learn how to test your error-handling code.
| Topic | Description | Link |
|---|---|---|
| Python Testing | unittest and pytest | https://tutorials.dodatech.com/programming-languages/python/py-testing/ |
| Python Context Managers | With-statement patterns | https://tutorials.dodatech.com/programming-languages/python/py-context-managers/ |
| Python Type Hints | Static typing with mypy | https://tutorials.dodatech.com/programming-languages/python/py-type-hints/ |
Practice tip: Add error handling to your BankAccount class from the https://tutorials.dodatech.com/programming-languages/python/py-oop/ tutorial — handle negative deposits, overdrafts, and invalid account numbers with custom exceptions.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro