Python Exceptions and Custom Errors

AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.

Python 3.13

A missing file, a dropped connection, malformed input: exceptions are how Python tells you that the normal path did not happen. Handling them well means the program fails in a way you can diagnose, instead of a stack trace from a line that looks unrelated.

Environment

Examples are verified on Python 3.13 with no extra dependencies.

The four blocks

try:
    value = int(input("number: "))
except ValueError:
    print("that is not a number")
else:
    print(f"got {value}")     # runs only if try succeeded
finally:
    print("cleanup")          # always runs

Execution order:

  1. try completes → else → finally.
  2. try raises and an except matches → that block → finally.
  3. try raises and nothing matches → finally → the exception propagates.

finally is for cleanup. With with handling most resource release, you rarely need it.

Catch what you can handle

try:
    with open("config.json", encoding="utf-8") as f:
        config = json.load(f)
except FileNotFoundError:
    config = {}                    # expected on first run
except json.JSONDecodeError as e:
    raise ConfigError("invalid config") from e   # cannot continue here

Rules that keep this readable:

  • Catch the narrowest exception you can actually recover from.
  • Order handlers from specific to general; ValueError before Exception.
  • as e binds the exception object; str(e) is its message.
  • To log and re-raise, use a bare raise so the original traceback survives.

else is the underused part: it keeps the “assume nothing failed” logic out of the try, so a later bug is not silently swallowed by an unrelated handler.

Raising exceptions

def divide(a, b):
    if b == 0:
        raise ValueError("b must not be zero")
    return a / b

Built-in types already cover most validation:

Situation Exception
Wrong value ValueError
Wrong type TypeError
Missing key KeyError
Index out of range IndexError
Missing file FileNotFoundError
Missing attribute AttributeError

Include the offending value in the message. raise ValueError(f"bad port: {port}") is worth ten seconds of typing when the log arrives at 3 a.m.

Chaining with raise ... from

When you translate a low-level failure into a domain error, keep the cause:

class ConfigError(Exception):
    pass

try:
    config = load_config()
except OSError as e:
    raise ConfigError("could not load config") from e

The traceback then shows both:

ConfigError: could not load config

The above exception was the direct cause of the following exception:

FileNotFoundError: [Errno 2] No such file or directory: 'config.json'

from None suppresses the original exception entirely. Use it only when the cause is genuinely noise, such as an implementation detail you are deliberately hiding from callers.

Defining custom exceptions

Business failures deserve names so callers can branch on them:

class AppError(Exception):
    """Base class for application errors."""

class ConfigError(AppError):
    pass

class ValidationError(AppError):
    def __init__(self, field: str, message: str):
        super().__init__(f"{field}: {message}")
        self.field = field

Usage:

try:
    save(user)
except ValidationError as e:
    print(f"field {e.field} failed: {e}")
except AppError:
    print("other application error")

Conventions:

  • Inherit from Exception, not BaseException; the latter includes KeyboardInterrupt and SystemExit.
  • One project-level base class lets an outer layer handle “any error of ours” in one place.
  • End names with Error.
  • Do not add a custom __str__ that drops the message you passed to super().__init__.

finally and return do not mix

def bad():
    try:
        return "try"
    finally:
        return "finally"   # discards "try" and swallows exceptions

A return inside finally overrides the try result and suppresses any in-flight exception. Never return from finally; manage state before entering try.

Common mistakes

1. Bare except or except Exception: pass

try:
    risky()
except Exception:
    pass          # the bug is now invisible

At minimum log it and re-raise:

import logging

try:
    risky()
except Exception:
    logging.exception("risky failed")   # includes the traceback
    raise

2. Using exceptions for ordinary flow

try:
    value = d["key"]
except KeyError:
    value = None

value = d.get("key") says the same thing without exception machinery.

3. Wrong handler order

except Exception placed first makes every later handler dead code. Linters flag this; do not rely on them catching it during review.

4. Catching inside a loop

One broad handler around a batch aborts all remaining items or hides which one failed. Catch per item, log the index, keep going, or let it fail on the first error deliberately.

5. Losing the traceback

except OSError as e:
    raise ConfigError(str(e))     # original traceback lost

Use from e, or let the original propagate.

6. Assuming str(e) is always useful

Some exceptions have empty messages. Log the type and the traceback, not just the message.

7. Raising in __init__ of exceptions incorrectly

Custom exceptions that do extra I/O or validation are fragile: raising an exception can fail with another exception, producing a confusing double traceback.

Summary

  • try / except / else / finally: else for success-only logic, finally for cleanup.
  • Catch only what you can handle; order handlers specific to general.
  • Use raise ... from e when translating errors, bare raise when re-throwing.
  • Custom exceptions: Exception subclass, project base class, Error suffix.
  • Never return from finally; never swallow exceptions silently.

That completes the Python basics series: values, control flow, strings, containers, functions, files, and errors.