Toolmingo
Guides7 min read

Python Exception Handling: try, except, finally, and Custom Exceptions

Master Python exception handling — try/except/else/finally, raising exceptions, custom exception classes, exception chaining, and real-world error patterns.

Python uses exceptions to signal that something went wrong at runtime. Understanding how to catch, raise, and design exceptions is essential for writing robust code. This guide covers everything from basic try/except to custom exception hierarchies and production patterns.


Quick Reference

Syntax Purpose
try: ... except ValueError: Catch a specific exception
except (TypeError, KeyError): Catch multiple exception types
except Exception as e: Catch any exception and bind it
else: Run if no exception was raised
finally: Always runs (cleanup)
raise ValueError("msg") Raise an exception
raise Re-raise the current exception
raise RuntimeError("x") from e Chain exceptions

Basic try / except

try:
    result = int("abc")
except ValueError as e:
    print(f"Conversion failed: {e}")

Catch multiple exception types in one handler:

try:
    data = config["timeout"]
    timeout = int(data)
except (KeyError, ValueError) as e:
    print(f"Config error: {e}")
    timeout = 30  # fallback

Catch multiple types separately (different handling):

try:
    value = process(data)
except ValueError as e:
    print(f"Bad value: {e}")
except TypeError as e:
    print(f"Wrong type: {e}")
except Exception as e:
    # Catch-all — log and re-raise, don't swallow silently
    logger.error("Unexpected error", exc_info=True)
    raise

else and finally

try:
    file = open("data.txt")
except FileNotFoundError:
    print("File not found")
else:
    # Runs only if no exception was raised in try
    content = file.read()
    print(f"Read {len(content)} bytes")
finally:
    # Always runs — perfect for cleanup
    file.close()

Context managers replace most finally blocks

# Preferred over manual try/finally for file handling
with open("data.txt") as file:
    content = file.read()
# file.close() is called automatically

Built-in Exception Hierarchy

BaseException
├── SystemExit
├── KeyboardInterrupt
├── GeneratorExit
└── Exception
    ├── ValueError
    ├── TypeError
    ├── KeyError
    ├── IndexError
    ├── AttributeError
    ├── FileNotFoundError (→ OSError)
    ├── PermissionError   (→ OSError)
    ├── TimeoutError      (→ OSError)
    ├── RuntimeError
    ├── StopIteration
    ├── ArithmeticError
    │   ├── ZeroDivisionError
    │   └── OverflowError
    └── ...

Rule: Catch the most specific exception type that makes sense. Never catch BaseException (that includes KeyboardInterrupt and SystemExit).


Common Built-in Exceptions

Exception When it occurs
ValueError Right type, wrong value — int("abc")
TypeError Wrong type — "a" + 1
KeyError Missing dict key — d["missing"]
IndexError List index out of range — lst[99]
AttributeError Missing attribute — None.upper()
FileNotFoundError File doesn't exist — open("x.txt")
PermissionError No filesystem permission
TimeoutError Operation timed out
ZeroDivisionError Division by zero
OverflowError Number too large
RecursionError Max recursion depth exceeded
MemoryError Out of memory
StopIteration Iterator exhausted (usually internal)
NotImplementedError Abstract method not implemented
AssertionError assert statement failed

Raising Exceptions

def divide(a: float, b: float) -> float:
    if b == 0:
        raise ZeroDivisionError("Denominator cannot be zero")
    return a / b

Re-raise after partial handling:

try:
    result = fetch_data(url)
except TimeoutError:
    logger.warning("Request timed out, retrying...")
    raise  # re-raise the original exception

Exception Chaining

Use raise ... from to preserve the original cause:

def load_config(path: str) -> dict:
    try:
        with open(path) as f:
            return json.load(f)
    except FileNotFoundError as e:
        raise RuntimeError(f"Config file missing: {path}") from e
    except json.JSONDecodeError as e:
        raise ValueError(f"Invalid JSON in config: {path}") from e

Output shows both exceptions:

RuntimeError: Config file missing: config.json
  The above exception was the direct cause of:
FileNotFoundError: [Errno 2] No such file or directory: 'config.json'

Suppress the chain with from None when the original cause is irrelevant:

try:
    value = mapping[key]
except KeyError:
    raise KeyError(f"Unknown setting: {key!r}") from None

Custom Exceptions

Basic custom exception

class AppError(Exception):
    """Base exception for this application."""
    pass

class ValidationError(AppError):
    """Input validation failed."""
    pass

class NotFoundError(AppError):
    """Resource not found."""
    pass

Usage:

def get_user(user_id: int):
    user = db.find(user_id)
    if user is None:
        raise NotFoundError(f"User {user_id} not found")
    return user

try:
    user = get_user(42)
except NotFoundError as e:
    return {"error": str(e)}, 404
except AppError as e:
    return {"error": "Application error"}, 500

Custom exceptions with extra context

class ValidationError(Exception):
    def __init__(self, message: str, field: str, value=None):
        super().__init__(message)
        self.field = field
        self.value = value

    def __str__(self):
        return f"[{self.field}] {super().__str__()}"


# Raise with context
raise ValidationError("Must be positive", field="age", value=-5)

# Catch and inspect
try:
    validate(data)
except ValidationError as e:
    print(f"Field: {e.field}, Value: {e.value}, Error: {e}")

Exception hierarchy for an API

class APIError(Exception):
    """Base exception for API errors."""
    status_code: int = 500

    def __init__(self, message: str, details: dict | None = None):
        super().__init__(message)
        self.details = details or {}

    def to_dict(self) -> dict:
        return {"error": str(self), "details": self.details}


class BadRequestError(APIError):
    status_code = 400


class UnauthorizedError(APIError):
    status_code = 401


class NotFoundError(APIError):
    status_code = 404


class RateLimitError(APIError):
    status_code = 429

    def __init__(self, retry_after: int):
        super().__init__("Too many requests")
        self.retry_after = retry_after

Real-World Patterns

Retry with exponential backoff

import time
import random
from typing import TypeVar, Callable

T = TypeVar("T")


def retry(
    func: Callable[[], T],
    *,
    attempts: int = 3,
    delay: float = 1.0,
    backoff: float = 2.0,
    exceptions: tuple = (Exception,),
) -> T:
    last_error: Exception | None = None
    wait = delay

    for attempt in range(1, attempts + 1):
        try:
            return func()
        except exceptions as e:
            last_error = e
            if attempt < attempts:
                jitter = random.uniform(0, wait * 0.1)
                time.sleep(wait + jitter)
                wait *= backoff
            else:
                raise RuntimeError(
                    f"All {attempts} attempts failed"
                ) from last_error

    raise last_error  # unreachable, satisfies type checkers


# Usage
result = retry(
    lambda: fetch_data(url),
    attempts=3,
    delay=1.0,
    exceptions=(TimeoutError, ConnectionError),
)

Collecting multiple errors

def validate_user(data: dict) -> dict:
    errors: list[str] = []

    if not data.get("name"):
        errors.append("name is required")
    if len(data.get("password", "")) < 8:
        errors.append("password must be at least 8 characters")
    if "@" not in data.get("email", ""):
        errors.append("email is invalid")

    if errors:
        raise ValidationError(
            f"{len(errors)} validation error(s)",
            field="__all__",
            value=errors,
        )
    return data

Exception logging pattern

import logging
import traceback

logger = logging.getLogger(__name__)


def safe_process(item):
    try:
        return process(item)
    except ValidationError as e:
        # Expected, log as warning
        logger.warning("Validation failed for item %s: %s", item, e)
        return None
    except Exception:
        # Unexpected, log full traceback
        logger.error("Unexpected error processing item %s", item, exc_info=True)
        raise

Context manager for error translation

from contextlib import contextmanager


@contextmanager
def translate_db_errors():
    """Translate database exceptions to application exceptions."""
    try:
        yield
    except DatabaseUniqueViolation as e:
        raise ValidationError("Duplicate entry") from e
    except DatabaseConnectionError as e:
        raise ServiceUnavailableError("Database unreachable") from e


# Usage
with translate_db_errors():
    user = db.create_user(email=email)

ExceptionGroup (Python 3.11+)

Python 3.11 introduced ExceptionGroup for handling multiple concurrent errors (e.g., from asyncio.TaskGroup):

import asyncio


async def main():
    try:
        async with asyncio.TaskGroup() as tg:
            tg.create_task(failing_task_1())
            tg.create_task(failing_task_2())
    except* ValueError as eg:
        print(f"Value errors: {eg.exceptions}")
    except* TypeError as eg:
        print(f"Type errors: {eg.exceptions}")

The except* syntax catches all exceptions of that type from the group.


Common Mistakes

Mistake Problem Fix
except Exception: pass Silently swallows all errors At minimum log the error
except: (bare) Catches SystemExit and KeyboardInterrupt Use except Exception:
Catching BaseException Prevents Ctrl+C from working Use except Exception:
except ValueError or TypeError: Always evaluates to except ValueError: Use except (ValueError, TypeError):
Catching too broadly (Exception) before specific types Specific handlers never run Order from specific → general
Using exceptions for control flow Slow and hard to read Use if/else for expected paths
raise Exception(e) instead of raise e from e Loses original traceback Use raise NewError(...) from e

TypeScript / Python Comparison

TypeScript Python
try { } catch (e) { } try: ... except Exception as e:
finally { } finally:
throw new Error("msg") raise ValueError("msg")
class MyError extends Error {} class MyError(Exception): pass
No built-in chaining raise B from A
No equivalent except* (ExceptionGroup, Py 3.11+)

FAQ

Should I always catch the most specific exception?
Yes. Catching Exception is a last resort. Specific types let you handle each failure mode correctly and don't accidentally hide bugs.

When should I use else vs putting code after try?
Use else for code that should only run if the try succeeded — it makes the intent clear and won't accidentally catch exceptions from your post-success code.

What's the difference between raise and raise e?
Bare raise re-raises the current exception and preserves the original traceback. raise e creates a new traceback starting at that line, making debugging harder.

When should I create a custom exception vs use a built-in?
Use built-ins when the semantics match (ValueError, KeyError, etc.). Create custom exceptions when callers need to distinguish your errors from other library errors, or when you need to attach extra context.

What does exc_info=True do in logging?
It attaches the current exception's traceback to the log record so it appears in log output — equivalent to logger.error("...", exc_info=sys.exc_info()).

Can I catch an exception and return a default value?
Yes, this is the EAFP (Easier to Ask Forgiveness than Permission) pattern — common in Python:

def safe_int(value, default=0):
    try:
        return int(value)
    except (ValueError, TypeError):
        return default

Keep reading

All Toolmingotools are free & run in your browser

No sign-up, no upload, no watermark. Your files never leave your device.

Browse all tools