Custom Exceptions & Exception Chaining

A custom exception is your own class that subclasses Exception , giving a failure a meaningful name callers can catch precisely — and exception chaining ( raise ... from ... ) links a high-level error to the lower-level cause that triggered it.

Learn Custom Exceptions & Exception Chaining in our free Python course — an interactive lesson with runnable examples, a practice exercise and a quick…

Part of the free Python course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

Robust programs don't just raise errors — they raise the right errors, organized into a hierarchy, with the original cause preserved. That's the difference between a cryptic stack trace and an error message that tells you exactly what went wrong and why.

Subclass Exception to create a named error. Group related errors under a common base so callers can catch the whole family at once or pick out a specific one:

Catching AppError grabs both subtypes; catching ValidationError grabs only that one. The hierarchy gives callers a choice of precision.

When a low-level error happens, you often want to raise a clearer, higher-level one — without losing the original. raise NewError(...) from original does exactly that, storing the original in __cause__ :

In a real traceback, chaining prints both errors joined by "The above exception was the direct cause of the following exception." You get a clean domain error and the technical root cause.

A full try statement has four clauses. else runs only on success; finally runs always; and a bare raise inside except re-raises the current exception after you've done something with it:

Sometimes several things fail at once and you want to report all of them, not just the first. Python 3.11 added ExceptionGroup and the except* syntax to raise and handle a bundle of exceptions:

Catching an exception is a commitment to handle it. If you can recover, substitute, retry, or translate it, catch it. If you can't, let it propagate — and never silence one you don't understand:

Complete the custom exception and the chaining. Replace each ___ , then run it.

✅ Subclass Exception (not BaseException ) for application errors:

✅ Catch what you can handle, and at least log the rest before re-raising with a bare raise .

✅ Use from to keep the chain explicit: raise ConfigError("bad config") from err .

Wrap json.loads so that any parse failure raises your own DataError chained from the original, while a clean value passes straight through.

Lesson complete — your error handling is professional now!

You can design custom exception hierarchies, chain errors with raise ... from ... , bundle failures with ExceptionGroup , use else and finally correctly, re-raise deliberately, and decide when catching even makes sense. Your tracebacks will tell the whole story now.

🚀 Up next: Time Zones with zoneinfo — handle aware datetimes correctly.

Practice quiz

Which base class should application exceptions subclass?

  • BaseException
  • RuntimeError
  • Exception
  • object

Answer: Exception. Subclass Exception for application errors. BaseException is too low-level and would also catch things like KeyboardInterrupt.

If ValidationError and ConfigError both subclass AppError, what does 'except AppError' catch?

  • Both ValidationError and ConfigError (the whole family)
  • Only AppError instances
  • Nothing
  • Only ValidationError

Answer: Both ValidationError and ConfigError (the whole family). Catching a base class catches all its subclasses, so 'except AppError' grabs the whole family of app errors at once.

What does 'raise NewError(...) from original' do?

  • Deletes the original exception
  • Ignores NewError
  • Re-runs the original code
  • Raises NewError while recording original as its __cause__

Answer: Raises NewError while recording original as its __cause__. The 'from' syntax chains exceptions: it raises the new error while storing the original in __cause__, preserving the root cause.

After 'raise ConfigError(...) from e', where is the original exception stored?

  • e.__context__ only
  • e.__cause__ of the new exception
  • It is lost
  • In a global variable

Answer: e.__cause__ of the new exception. The original is recorded as the new exception's __cause__, so you can inspect it and the traceback shows both errors.

When does the 'else' block of a try statement run?

  • Only when the try body completed with no exception
  • Always
  • Only when an exception was raised
  • Never

Answer: Only when the try body completed with no exception. The else clause runs only on success — when the try body raised no exception — and its own errors are not caught by the except.

When does the 'finally' block run?

  • Only on success
  • Only on failure
  • No matter what — exception or not, even after a return
  • Only if there is no except clause

Answer: No matter what — exception or not, even after a return. finally always runs, making it the right place for cleanup like closing files or releasing locks.

What does a bare 'raise' inside an except block do?

  • Raises a new generic error
  • Re-raises the current exception unchanged
  • Silences the exception
  • Returns None

Answer: Re-raises the current exception unchanged. A bare 'raise' re-raises the exception currently being handled, so you can log it and then let it propagate.

What does 'except* ValueError' (with the star) handle?

  • A single ValueError
  • All exceptions
  • Only TypeErrors
  • ValueErrors peeled from an ExceptionGroup

Answer: ValueErrors peeled from an ExceptionGroup. except* matches and handles the exceptions of a given type within an ExceptionGroup, which can bundle many exceptions together (Python 3.11+).

What is wrong with 'except Exception: pass'?

  • It is a syntax error
  • It silently swallows errors, hiding real bugs
  • It only works once
  • It re-raises automatically

Answer: It silently swallows errors, hiding real bugs. Silently swallowing exceptions hides bugs (even typos). Catch only what you can handle, and at least log the rest.

In 'load_port' that does 'raise ConfigError(...) from e' on a bad int, what is e.__cause__?

  • A ConfigError
  • None
  • A ValueError
  • A TypeError

Answer: A ValueError. int('abc') raises ValueError, which is captured as e and becomes the __cause__ of the chained ConfigError.