Python Custom Exceptions: Clear Validation, Error Chaining and Recovery

Python course module guide: Exception management

Python custom exceptions give domain failures meaningful names. A clear exception boundary helps callers distinguish invalid input, missing records and unavailable services without parsing vague message text.

What you will learn

  • Create a small domain exception hierarchy.
  • Raise errors at the point of knowledge.
  • Preserve causes with raise ... from.
  • Catch failures only where recovery is possible.

Why create a custom exception?

Built-in exceptions such as ValueError and KeyError are often sufficient inside small functions. A custom exception becomes useful when the failure belongs to the application’s domain and callers need to react differently. Names such as InvalidEnrollment or PaymentDeclined communicate intent better than a generic error string.

class EnrollmentError(Exception):
    """Base error for enrolment operations."""

class InvalidCourseCode(EnrollmentError):
    pass

class SeatUnavailable(EnrollmentError):
    pass

Keep the hierarchy small. A shared base class lets an application catch every enrolment failure when appropriate while still allowing specific handling.

Raise at the point of knowledge

def reserve_seat(course_code: str, available_seats: int) -> str:
    normalized = course_code.strip().upper()
    if not normalized.startswith("PY-"):
        raise InvalidCourseCode(f"Unsupported course code: {normalized}")
    if available_seats <= 0:
        raise SeatUnavailable(f"No seats available for {normalized}")
    return f"Seat reserved for {normalized}"

The function that knows the rule should raise the error. A user interface, command handler or API route can decide how to present it. Avoid returning a mixture of strings, None and special numbers to represent failures.

Catch only where recovery is possible

try:
    message = reserve_seat(user_code, seats)
except InvalidCourseCode as error:
    print(f"Check the course code. {error}")
except SeatUnavailable:
    print("Choose another batch or join the waiting list.")
else:
    print(message)

Each handler provides a specific next action. A broad except Exception at every layer usually hides programming errors and creates misleading success states. The Python debugging guide explains how to read tracebacks instead of suppressing them.

Preserve the original cause

class ConfigurationError(Exception):
    pass

def read_port(value: str) -> int:
    try:
        port = int(value)
    except ValueError as error:
        raise ConfigurationError("PORT must be an integer") from error
    if not 1 <= port <= 65535:
        raise ConfigurationError("PORT is outside the valid range")
    return port

raise ... from error adds domain context while preserving the underlying cause. This is valuable when logs need both the user-facing meaning and the technical origin.

Add useful attributes

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

raise ValidationError("phone", "must contain 10 digits")

Structured attributes are easier to map to a form or JSON response than parsing text. Do not put passwords, tokens or sensitive personal data into exception messages because logs may store them.

Map exceptions at application boundaries

A CLI can translate a domain error into a clear message and non-zero exit code. A web API can translate it into a suitable HTTP status and JSON error object. Keep HTTP concepts out of the core domain function when that function may also be used by a script or background job. The Flask REST API with SQLite demonstrates this separation.

Test the failure contract

import pytest

def test_rejects_unknown_course_code():
    with pytest.raises(InvalidCourseCode, match="Unsupported course code"):
        reserve_seat("java-01", 3)

def test_rejects_full_batch():
    with pytest.raises(SeatUnavailable):
        reserve_seat("PY-01", 0)

Tests document which failures callers can depend on. Continue with the pytest guide for fixtures, parametrization and monkeypatching.

File and data errors

Do not replace every FileNotFoundError or CSV parsing error with one vague custom exception. Add domain context only when it helps the caller act. The Python file handling guide covers the built-in failures you should understand first.

Common mistakes

  • Creating a separate exception class for every sentence.
  • Catching an error and returning success anyway.
  • Losing the original traceback when translating exceptions.
  • Using exception messages as program logic instead of exception types or attributes.
  • Exposing secrets or internal SQL details to users.
  • Using exceptions for ordinary expected branching that a return value could express clearly.

Practice exercise

Design exceptions for a small library system: invalid member, unknown book and unavailable copy. Raise them in domain functions, translate them into CLI messages at the outer boundary and write one test per failure. Add error chaining when converting a malformed numeric identifier.

Custom exception FAQs

Should custom exceptions inherit from Exception?

Application exceptions normally inherit directly or indirectly from Exception, not BaseException, so ordinary handlers behave as expected.

When is ValueError enough?

Use it when the caller only needs to know that a value is invalid. Create a domain type when callers need a stable, meaningful category for recovery.

Should error messages be shown directly to users?

Not always. Translate internal errors at the UI or API boundary, especially when messages could reveal implementation or sensitive information.

A recovery policy starts with the caller’s next action

Consider the enrolment example as a teaching model, not a complete booking system. Its function returns a message after checking inputs; it does not persist a reservation or prevent two users taking the final seat simultaneously. An exception hierarchy describes failures, but it does not replace a transaction or concurrency control around real seat allocation.

Write a small recovery table for the actual application. An invalid course code asks for corrected input. An unavailable seat suggests a different batch. A temporary storage failure may be retried under a bounded policy. A programming error should reach diagnostics instead of being relabelled as a normal rejection. This keeps the interface from telling the user to fix information that was already valid.

Keep the protected operation narrow

In the configuration parser above, only integer conversion belongs inside the ValueError handler. The range check follows it because an integer outside the accepted range is a different validation result. If an unrelated operation inside a large try block raises ValueError, an overly broad handler can falsely report that the port text was malformed.

Document the input type as well as the accepted values. The read_port function expects a string. Passing None raises TypeError during conversion, which the current function does not translate. Decide whether an outer configuration reader must reject missing values first or whether the parser should explicitly support that case. Do not broaden the handler simply to make every unexpected input look identical.

Verify error chaining as observable behaviour

For malformed text, the custom ConfigurationError should retain the ValueError as its explicit cause. For a numeric but out-of-range value, the function raises its own domain error without a conversion failure. These cases should have different diagnostic histories even if both become a concise configuration message at the interface.

def check_port_error_chain():
    try:
        read_port("invalid")
    except ConfigurationError as error:
        assert isinstance(error.__cause__, ValueError)
    else:
        raise AssertionError("Expected ConfigurationError")

    try:
        read_port("70000")
    except ConfigurationError as error:
        assert error.__cause__ is None
    else:
        raise AssertionError("Expected range failure")

The small diagnostic above checks the existing parser directly without a testing dependency. It complements a normal pytest exception test; it does not replace the rest of the test suite. Also verify valid boundaries such as 1 and 65535 and the expected result for an ordinary value such as 8080.

Python’s explicit from syntax identifies the underlying cause. Using from None suppresses its normal display, which can be appropriate at a deliberately chosen boundary, but should not become a way to hide useful failure evidence throughout the program. Keep sensitive technical details out of public messages while retaining appropriate diagnostics for maintainers.

Cleanup and partial success need their own design

If a workflow writes a file and then updates a database, raising an exception on the second step does not undo the file write. Decide whether to use a transaction, a temporary output followed by a final publish step, or an explicit compensating action. Document the state that remains after failure so a retry does not silently duplicate work.

Use cleanup mechanisms such as a context manager or finally for owned resources. Avoid returning success from finally: a return there can suppress a pending exception. A cleanup failure also deserves visibility because it can obscure the original problem. Keep recovery actions small enough that their own failure paths can be understood.

Maintain stable error categories

Application code should usually branch on the exception type or a documented attribute, not an exact English sentence. Messages can become clearer or be translated without changing the recovery contract. Use a stable field identifier for form validation and present a reader-friendly message separately.

When changing an error hierarchy, inspect callers that catch the old base class. An innocent rename can make a previously handled failure escape unexpectedly. Keep a focused test showing that specific errors remain catchable through the intended domain base, and check that unrelated programming failures are not swallowed by that boundary.

Where should logging happen?

Log where the application has enough context and responsibility to report the failure, usually at an outer boundary. Repeatedly logging and re-raising the same event at every layer produces duplicate alerts. Include the operation and a safe correlation identifier, then explain whether the action failed, was retried or completed only partially.

Further official references

Official reference: Python tutorial on user-defined exceptions.

Learn Python with guided practice in Visakhapatnam

These concepts become useful when you apply them in exercises, assignments and reviewed projects. Explore Python training in Vizag for the complete curriculum, classroom and online learning options, and current batch details.

Python module learning path

Continue through the related practical guides in this course-module series:

  1. Python match-case pattern matching
  2. Python list comprehensions
  3. Python iterators and generators
  4. Python decorators
  5. Python context managers
  6. Python custom exceptions
  7. pytest unit testing for beginners
  8. Python API pagination and retries
  9. pandas GroupBy and merge
  10. Flask REST API with SQLite

Leave a Comment

Your email address will not be published. Required fields are marked *