Explain Failures with Exception Chaining
Raise a domain-specific error from a lower-level exception without losing the original cause.
Example check: Worked and guided code samples are checked against CPython 3.13. Practice uses Pyodide 0.29.5 (Python 3.13) in your browser. How we check lessons
Video lesson: Explain Failures with Exception Chaining
The preview is stored on this site. YouTube loads only when you press Play. Watch on YouTube
Jump to a chapter
Read the video transcript
Select a timestamp to watch that moment on YouTube.
- 00:00
When a program reads configuration text, it often has to translate a low-level parsing error into a message that tells a person which setting to fix. Suppose a supplied port is the word many. Python's int conversion rightly raises ValueError. A tempting except block catches it and returns a default port, so the service appears to start. The operator may spend time investigating a network problem without realizing the supplied setting was ignored. Another approach raises a new ConfigError, but if we are careless we lose a clear link to the original conversion failure. In this lesson we will run the bad default behavior, inspect the conversion error,
- 00:41
and use `raise ... from ...` to explain the configuration problem while preserving its cause. We will then separate a malformed text value from a correctly parsed number outside the valid port range. The two failures should not claim the same underlying cause. The companion PythonLessonLab exercise has a browser console, checked task, examples, and quiz. By the end, you should be able to say what the caller sees, what a developer can trace, and why a range error has no conversion exception to chain. There is no exception and the program receives port eight thousand. But the person who supplied many did not request that port.
- 01:23
The fallback is especially misleading when a configuration value was explicitly set: it turns bad input into an apparently healthy startup. Defaults are useful when a setting is absent and the contract says what default to use. They should not automatically replace a malformed supplied value. We can diagnose this by running the conversion by itself. The underlying operation is `int(raw)`, which cannot parse many as a whole number. The low-level ValueError tells us the text conversion failed, but its generic message may not identify the setting that matters to the operator. Our repaired parser will attach a port- specific message and still retain that
- 02:05
conversion exception as the cause. We also need a separate range check after conversion. A value of zero is numeric, yet it is outside our chosen allowed range of one through 65535. Treating zero as an integer-format error would be inaccurate. Good error messages should reflect the actual decision that failed. This is the exact low-level exception the parser should translate. We are catching ValueError around only the conversion we understand. If we wrapped the entire application in `except Exception`, a bug elsewhere could be mislabeled as a bad port, delaying the real repair.
- 02:48
Python does record implicit exception context when a second exception is raised while handling the first. Explicit chaining goes further: `raise ConfigError(...) from error` states the causal relationship directly and sets the new exception's `__cause__` attribute. A full traceback normally displays both exceptions with an explanatory separator. For stable examples and tests, we will inspect `__cause__` rather than rely on file paths and line numbers in a printed traceback. The higher-level ConfigError can say what setting is wrong, while the original ValueError still tells a developer that integer parsing failed.
- 03:29
We should not include secret configuration values in log messages just to be descriptive. A port is public in this example, but the same pattern may be used for credentials, so keep the message focused on the rule rather than echoing raw input. A valid port returns as an integer. For many, int raises ValueError, and the except block raises ConfigError from that very exception object. The printed `__cause__` confirms the link. The caller can display or log the domain message without pretending the original parsing failure vanished. The range branch follows only after conversion succeeds. It raises ConfigError directly because no int failure occurred.
- 04:13
This distinction matters when investigating incidents: malformed text and out-of-range numeric values require different corrections. The class ConfigError inherits ValueError here, which is a reasonable small domain choice; a larger project might define a different exception hierarchy. What matters in this example is that the parser's public contract is clear. It returns a valid port or raises an error that names the violated rule. We do not return a default after a bad supplied value, and we do not catch unrelated exceptions. If you build a configuration loader from several settings, apply the same narrow
- 04:53
translation at each boundary rather than wrapping everything in one broad catch. We first test the two endpoints that our contract allows. The conversion branch is tested with many, and its ConfigError must have a ValueError cause. The range branch is tested with zero, which int parses successfully; that ConfigError must have no explicit cause. If we accidentally chain a fabricated conversion error there, the second assertion would expose it. Notice the difference between `__cause__` and all possible implicit context: `__cause__` specifically records an explicit `from` relationship. These checks do not cover every possible Python object passed to int, and our task
- 05:35
contract focuses on string settings. They do cover the decisions we promised to explain. In the browser lesson, complete parse_port yourself, then run the task checker. If the prepared output prints NoneType instead of ValueError for a malformed port, your new exception was raised without `from original`. If a numeric value outside the range returns successfully, the range guard is missing. After the quiz, try adding a second setting with its own domain message. Keep each except block narrow enough that the cause remains honest. Clear failures close to the input boundary save more debugging time than a silent
- 06:15
fallback that merely keeps a process running.
Understand the concept
A low-level error often explains what operation failed but not which user setting caused it. For example, int('many') raises ValueError. A configuration parser can report 'port must be an integer' so a caller knows what to repair. If it simply raises a new exception while handling the old one, Python records context, but the relationship may be less explicit. `raise ConfigError(...) from error` sets the new error's `__cause__` to the original exception.
Exception chaining does not mean swallowing a failure and substituting a default port. A malformed supplied setting should fail near startup, where the message still has context. Catch only the conversion error you understand; a broad `except Exception` could hide bugs in the parser or other code. A range failure is different from a conversion failure: if a numeric port is zero or above 65535, raise a ConfigError directly. There is no lower-level conversion exception to chain in that branch.
When printing a full traceback, Python displays both the cause and the higher-level error, linked by an explanatory line. The exercise uses `error.__cause__` to make that relationship testable without relying on the full traceback's file paths and line numbers. The source error remains useful for debugging, while the higher-level message is aimed at the person configuring the application.
The task defines ConfigError and parse_port. It converts a supplied string to int, chains a ConfigError from ValueError only when conversion fails, and validates the inclusive 1..65535 range. Hidden checks distinguish the two failure paths and inspect the exact cause type. First reproduce int('many') in the guide; then repair the parser and verify a valid port, a malformed string, and an out-of-range integer. Never include secrets from configuration values in a logged error message.
- raise NewError(...) from original sets __cause__
- Catch only the low-level failure you can explain
- Range failures can be raised directly without a conversion cause
- Preserving causes helps diagnosis without leaking full input values
See it step by step
Read the code, predict the output, then compare it with the result.
01. Observe the original conversion error
try:
int("many")
except ValueError as error:
print(type(error).__name__)
print("invalid literal" in str(error))ValueError
TrueThe conversion failure is real, but its message does not name the port setting.
02. Attach a useful domain message and preserve the cause
class ConfigError(ValueError):
pass
def read_port(text):
try:
return int(text)
except ValueError as original:
raise ConfigError("invalid port") from original
try:
read_port("many")
except ConfigError as error:
print(str(error))
print(type(error.__cause__).__name__)invalid port
ValueErrorThe caller sees the configuration message and the original conversion exception remains linked.
03. Keep a range error separate
class ConfigError(ValueError):
pass
def checked_port(text):
value = int(text)
if not 1 <= value <= 65535:
raise ConfigError("out of range")
return value
try:
checked_port("0")
except ConfigError as error:
print(str(error))
print(error.__cause__ is None)out of range
TrueThe number parsed successfully; there is no conversion error to chain.
A closer look
Follow the reasoning, inspect each result, then try the suggested changes in the console below.
See why a default conceals bad configuration
The built-in int conversion reports ValueError when a text setting is not numeric. A tempting workaround catches the error and silently returns a familiar default. That makes the program appear to start successfully while ignoring the operator's supplied value. A production service might listen on a port nobody expected. The first run prints the default despite receiving 'many'.
The correct response is to fail with a useful message at the configuration boundary. We also want to retain the original conversion failure so a developer can see the underlying operation. A domain-specific ConfigError communicates which setting is wrong; explicit exception chaining records why it was raised.
def broken_parse_port(raw):
try:
return int(raw)
except ValueError:
return 8000
print(broken_parse_port("many"))8000- Run the malformed setting and notice there is no visible failure.
- Ask whether a silently substituted port is safer than a clear startup error.
Raise a useful error from the original cause
Catch only ValueError from the int conversion. The `from original` clause explicitly attaches that exception to ConfigError. The caller can show the short configuration message, while a diagnostic traceback retains the conversion failure. Inspecting `__cause__` here avoids printing environment-specific line numbers or file paths into the example output.
Do not use a broad except around the entire configuration procedure. It might translate a programming mistake into a misleading user-input message. Also avoid including secret values in a raised or logged error. For a public port number, a generic message is enough to guide the correction without echoing whatever was supplied.
class ConfigError(ValueError):
pass
def parse_port(raw):
try:
return int(raw)
except ValueError as original:
raise ConfigError("port must be an integer") from original
try:
parse_port("many")
except ConfigError as error:
print(error)
print(type(error.__cause__).__name__)port must be an integer
ValueError- Follow the conversion ValueError into the except block.
- Read the ConfigError and its explicit __cause__ separately.
Distinguish conversion errors from range errors
A numeric value can parse successfully and still violate the port contract. The inclusive valid range is one through 65535. Raise a ConfigError directly for zero or 65536; there is no conversion failure to attach. This difference matters when diagnosing a problem: the text format was valid, but the allowed range was not.
Verify both boundaries and both failure paths. The malformed text must produce a ConfigError whose cause is a ValueError. The out-of-range number must produce a ConfigError with no explicit cause. The exercise's hidden checks inspect that distinction. This is a small example of a broader rule: translate low-level failures only when the translation gives the caller useful context, and preserve truthful causality.
class ConfigError(ValueError):
pass
def parse_port(raw):
try:
port = int(raw)
except ValueError as original:
raise ConfigError("port must be an integer") from original
if not 1 <= port <= 65535:
raise ConfigError("port must be 1..65535")
return port
print(parse_port("1"), parse_port("65535"))
for text in ("many", "0"):
try:
parse_port(text)
except ConfigError as error:
print(str(error), type(error.__cause__).__name__ if error.__cause__ else "no cause")1 65535
port must be an integer ValueError
port must be 1..65535 no cause- Run the two inclusive valid boundaries.
- Compare the cause of malformed text with the direct range failure.
Try it in Python
Edit the example and run it. Python starts in your browser the first time you click Run.
Python console
Ready to runNeed input()? Add one value per line
Your output appears here.
Need a hint?
Replace the except body with `raise ConfigError('port must be an integer') from error`. After the try/except, use `if not 1 <= port <= 65535: raise ConfigError('port must be 1..65535')`, then return port.
Complete the task and select Check task to verify your code.
Quick quiz
Three questions. You can change your answers and try again.
Typical mistakes
Everyone meets these errors. See what causes them and how to fix them.
Losing the explicit cause
except ValueError:
raise ConfigError('invalid port')except ValueError as error:
raise ConfigError('invalid port') from errorWhat happens: The new error does not explicitly declare its original cause.
Explicit chaining sets __cause__ and clarifies the diagnostic link.
Silently defaulting malformed input
except ValueError:
return 8000except ValueError as error:
raise ConfigError('port must be an integer') from errorWhat happens: A bad supplied setting is quietly ignored.
Fail near the configuration boundary with a useful message.