Skip to content
Python Error Handling — Exceptions, Logging & Best Practices

Python Error Handling — Exceptions, Logging & Best Practices

DodaTech Updated Jun 15, 2026 6 min read

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
  • assert for 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()
BlockWhen It Runs
tryAlways — the code to monitor
exceptOnly if an exception is raised
elseOnly if NO exception was raised
finallyAlways — 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 available

Exception 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 e

The 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")
LevelWhen to Use
DEBUGDetailed diagnostic info
INFONormal operations
WARNINGSomething unexpected but not critical
ERRORFunction couldn’t complete
CRITICALProgram 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!
    pass

Fix: 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 generic

Fix: 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?
tryelsefinally.

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 / b

Mini 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 words

What’s Next

Error handling and testing go hand in hand. Next, learn how to test your error-handling code.

TopicDescriptionLink
Python Testingunittest and pytesthttps://tutorials.dodatech.com/programming-languages/python/py-testing/
Python Context ManagersWith-statement patternshttps://tutorials.dodatech.com/programming-languages/python/py-context-managers/
Python Type HintsStatic typing with mypyhttps://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