In the previous tutorial, we learned about dataclasses and Pydantic. Now let’s learn about error handling — how to write code that fails gracefully instead of crashing.

Every program encounters errors. Files go missing, networks fail, users enter bad data. Good error handling is the difference between a program that crashes with a confusing traceback and one that tells the user what went wrong and how to fix it. By the end of this tutorial, you will know how to catch errors, create custom exceptions, and choose the right error handling pattern.

Basic try/except

Use try/except to catch errors and handle them:

def safe_divide(a: float, b: float) -> float | None:
    """Divide a by b, returning None if b is zero."""
    try:
        return a / b
    except ZeroDivisionError:
        return None

print(safe_divide(10, 3))  # 3.333...
print(safe_divide(10, 0))  # None (instead of crashing)

The try block runs the code. If an error occurs, Python jumps to the matching except block. If no error occurs, the except block is skipped.

Without try/except, dividing by zero would crash the program with ZeroDivisionError: division by zero. With error handling, we return None instead.

Catching Specific Exceptions

Always catch specific exceptions. Never use bare except: — it catches everything, including keyboard interrupts (Ctrl+C) and system exits.

# GOOD — catches only ValueError
def parse_int(value: str) -> int | None:
    try:
        return int(value)
    except ValueError:
        return None

# GOOD — catches multiple specific types
def get_item(data: list | dict, key):
    try:
        return data[key]
    except (IndexError, KeyError, TypeError):
        return None

# BAD — catches everything! Never do this.
try:
    result = do_something()
except:
    pass  # Swallows ALL errors, even Ctrl+C

If you are not sure which exception to catch, start without try/except. Let the error happen, read the traceback, and then add a specific except clause.

Common built-in exceptions:

ExceptionWhen it happens
ValueErrorWrong value (e.g., int("abc"))
TypeErrorWrong type (e.g., len(42))
KeyErrorMissing dict key (e.g., d["missing"])
IndexErrorList index out of range (e.g., lst[100])
FileNotFoundErrorFile does not exist
ZeroDivisionErrorDivision by zero
AttributeErrorObject has no such attribute
PermissionErrorNo permission to access a file
ConnectionErrorNetwork connection failed

All built-in exceptions inherit from BaseException. Most of them inherit from Exception. You should only catch exceptions that inherit from Exception — never catch BaseException directly.

try/except/else/finally

The full try statement has four parts:

def read_number(value: str) -> dict:
    """Parse a number string and return status info."""
    result = {"success": False, "value": None, "cleaned_up": False}
    try:
        number = float(value)
    except ValueError:
        result["error"] = f"Cannot parse '{value}' as a number"
    else:
        # Runs only if NO exception occurred
        result["success"] = True
        result["value"] = number
    finally:
        # Runs ALWAYS, even after an exception or a return
        result["cleaned_up"] = True
    return result
print(read_number("3.14"))
# {'success': True, 'value': 3.14, 'cleaned_up': True}

print(read_number("abc"))
# {'success': False, 'value': None, 'cleaned_up': True, 'error': "Cannot parse 'abc' as a number"}

Here is what each part does:

  • try — code that might fail
  • except — handles the error
  • else — runs only if try succeeds (no exception). Put the “happy path” code here.
  • finally — runs always, even after an exception or a return statement. Use it for cleanup.

The else block is optional but useful. It separates error handling from success logic. Without else, you might accidentally catch exceptions from your success code:

# Without else — process_number errors are also caught!
try:
    number = float(value)
    result = process_number(number)  # If this fails, ValueError is caught too
except ValueError:
    print("Bad input")

# With else — only float() errors are caught
try:
    number = float(value)
except ValueError:
    print("Bad input")
else:
    result = process_number(number)  # Errors here are NOT caught

The finally block is useful for closing files, database connections, or network sockets. It runs even if the function returns early from inside the try block.

Custom Exceptions

Create your own exceptions by inheriting from Exception. This lets you add extra information and create a hierarchy of error types:

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

class NotFoundError(AppError):
    """Raised when a resource is not found."""

    def __init__(self, resource: str, resource_id: str) -> None:
        self.resource = resource
        self.resource_id = resource_id
        super().__init__(f"{resource} with id '{resource_id}' not found.")

class ValidationError(AppError):
    """Raised when input validation fails."""

    def __init__(self, field: str, message: str) -> None:
        self.field = field
        self.message = message
        super().__init__(f"Validation error on '{field}': {message}")

Custom exceptions carry extra information:

try:
    raise NotFoundError("User", "42")
except NotFoundError as e:
    print(e)               # User with id '42' not found.
    print(e.resource)      # User
    print(e.resource_id)   # 42

The key design pattern: create a base exception for your application (AppError). Then create specific exceptions that inherit from it. This gives callers flexibility:

# Catch all application errors
except AppError as e:
    log_error(e)

# Or catch specific errors
except NotFoundError as e:
    return 404
except ValidationError as e:
    return 400

Using Custom Exceptions in Practice

Here is a realistic example:

_users_db = {
    "1": {"name": "Alex", "email": "alex@example.com"},
    "2": {"name": "Sam", "email": "sam@example.com"},
}

def get_user(user_id: str) -> dict:
    """Get a user by ID. Raises NotFoundError if not found."""
    if user_id not in _users_db:
        raise NotFoundError("User", user_id)
    return _users_db[user_id]

def validate_email(email: str) -> str:
    """Validate an email address. Raises ValidationError if invalid."""
    if not email:
        raise ValidationError("email", "Email cannot be empty.")
    if "@" not in email:
        raise ValidationError("email", "Email must contain @.")
    return email.lower()

Using these functions:

# Happy path
user = get_user("1")
print(user["name"])  # Alex

# Error path with specific handling
try:
    user = get_user("99")
except NotFoundError as e:
    print(f"Not found: {e.resource} {e.resource_id}")

# Validation
try:
    email = validate_email("invalid")
except ValidationError as e:
    print(f"Field '{e.field}': {e.message}")

This pattern is common in web APIs: the route handler calls service functions that raise custom exceptions, and middleware converts those exceptions into HTTP responses.

Exception Chaining: raise … from

When you catch one exception and raise another, use raise ... from to preserve the original error. This creates a chain that helps with debugging:

def load_config(data: dict) -> dict:
    """Load config from a dict. Chains exceptions for context."""
    try:
        name = data["name"]
        port = int(data["port"])
    except KeyError as e:
        raise AppError(f"Missing config key: {e}") from e
    except ValueError as e:
        raise AppError(f"Invalid config value: {e}") from e
    return {"name": name, "port": port}

The from e connects the new exception to the original. When you see the traceback, it shows both:

try:
    load_config({"name": "app"})
except AppError as e:
    print(e)            # Missing config key: 'port'
    print(e.__cause__)  # KeyError('port')

Without from, the traceback shows “During handling of the above exception, another exception occurred” which is confusing. With from, it shows “The above exception was the direct cause of the following exception” which is clear.

Exception Groups (Python 3.11+)

Sometimes you want to collect multiple errors at once instead of stopping at the first one. This is common in form validation. Use ExceptionGroup:

def validate_form(data: dict) -> dict:
    """Validate a form. Collects all errors at once."""
    errors = []

    if not data.get("name", "").strip():
        errors.append(ValidationError("name", "Name is required."))

    email = data.get("email", "")
    if not email:
        errors.append(ValidationError("email", "Email is required."))
    elif "@" not in email:
        errors.append(ValidationError("email", "Email must contain @."))

    age = data.get("age")
    if age is not None and (not isinstance(age, int) or age < 0):
        errors.append(ValidationError("age", "Age must be a positive integer."))

    if errors:
        raise ExceptionGroup("Form validation failed", errors)
    return data

Catch exception groups like this:

try:
    validate_form({"name": "", "email": "invalid", "age": -5})
except ExceptionGroup as eg:
    for error in eg.exceptions:
        print(f"  - {error.field}: {error.message}")
    # - name: Name is required.
    # - email: Email must contain @.
    # - age: Age must be a positive integer.

This is much better than stopping at the first error. Users see all problems at once and can fix them in one go.

Python 3.11 also added except* for catching specific exception types within a group, but the basic pattern above covers most use cases.

contextlib.suppress

For simple cases where you just want to ignore a specific exception, use contextlib.suppress. It is cleaner than a try/except that does nothing:

import contextlib

# Instead of this:
try:
    user = get_user("99")
except NotFoundError:
    user = None

# You can write this:
user = None
with contextlib.suppress(NotFoundError):
    user = get_user("99")

Both versions do the same thing. Use suppress only for simple cases where the except block would be empty or just set a default value. For anything more complex, use try/except.

LBYL vs EAFP

Python has two error handling philosophies:

LBYL — Look Before You Leap: Check for errors before they happen.

def lbyl_get_value(data: dict, key: str, default=None):
    if key in data:        # Check first
        return data[key]   # Then access
    return default

EAFP — Easier to Ask Forgiveness than Permission: Try first, handle errors after.

def eafp_get_value(data: dict, key: str, default=None):
    try:
        return data[key]   # Try first
    except KeyError:
        return default     # Handle error

Python strongly prefers EAFP. Here is why:

  1. It is often faster — in the happy path (no error), try/except has almost zero overhead. LBYL always runs the check.
  2. It avoids race conditions — between checking and accessing, the state might change (e.g., a file could be deleted).
  3. It is more Pythonic — the Python community convention.

Use LBYL only when the check is much simpler than catching the exception, or when the exception is very expensive.

When to Use Each Pattern

PatternBest for
try/exceptHandling expected errors gracefully
try/except/elseSeparating error handling from success logic
try/finallyCleanup code that must always run
Custom exceptionsLibraries and applications with specific error types
raise ... fromWrapping low-level errors with high-level context
ExceptionGroupCollecting multiple errors (form validation)
contextlib.suppress()Silently ignoring a specific exception
EAFPThe common case in Python
LBYLWhen the check is simpler than the exception

Common Mistakes

Catching Too Broadly

# BAD — hides bugs!
try:
    result = complex_function()
except Exception:
    result = default_value

# GOOD — catch only what you expect
try:
    result = complex_function()
except (ValueError, KeyError) as e:
    logger.warning(f"Expected error: {e}")
    result = default_value

Silencing Errors

# BAD — you will never know something went wrong
try:
    send_email(user)
except Exception:
    pass

# GOOD — at least log the error
try:
    send_email(user)
except SMTPError as e:
    logger.error(f"Failed to send email: {e}")

Using Exceptions for Control Flow

# BAD — exceptions should be exceptional
try:
    value = lst[index]
except IndexError:
    value = "default"

# GOOD — use normal control flow when possible
value = lst[index] if index < len(lst) else "default"

The Exception Hierarchy

All Python exceptions inherit from BaseException. Here is the hierarchy:

BaseException
├── SystemExit
├── KeyboardInterrupt
├── GeneratorExit
└── Exception
    ├── StopIteration
    ├── ArithmeticError
    │   └── ZeroDivisionError
    ├── LookupError
    │   ├── IndexError
    │   └── KeyError
    ├── OSError
    │   ├── FileNotFoundError
    │   └── PermissionError
    ├── ValueError
    ├── TypeError
    └── RuntimeError

Important rules:

  • Never catch BaseException — it includes KeyboardInterrupt (Ctrl+C) and SystemExit
  • Catch Exception as the broadest reasonable catch — but prefer specific exceptions
  • Your custom exceptions should inherit from Exception, not BaseException

Source Code

You can find the code for this tutorial on GitHub:

kemalcodes/python-tutorial — tutorial-11-error-handling

Run the examples:

python src/py11_error_handling.py

Run the tests:

python -m pytest tests/test_py11.py -v

What’s Next?

In the next tutorial, we will learn about file I/O — reading, writing, and working with JSON, CSV, and temporary files.