Skip to content

Python Error Handling — Exceptions, Logging & Best Practices

DodaTech Updated 2026-06-15 6 min read

In this tutorial, you'll learn about Python Error Handling. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.

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
â„šī¸ Info

Prerequisite: Understand Python functions and file I/O. Review {{< ref "py-io" >}} and {{< ref "py-functions" >}} first.

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 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 {{< ref "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!
âš ī¸ Warning

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!
    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?
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 / 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.

Topic Description Link
Python Testing unittest and pytest {{< ref "py-testing" >}}
Python Context Managers With-statement patterns {{< ref "py-context-managers" >}}
Python Type Hints Static typing with mypy {{< ref "py-type-hints" >}}

Practice tip: Add error handling to your BankAccount class from the {{< ref "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