Skip to content
aviral gupta

// I1.2 · ~30 min · Intermediate

Exception chaining

After this lesson you can read a traceback that shows two exceptions, turn a low-level error into your own without losing it, and hide an original error that only adds noise.

Lesson 2 of 5 in I1 Errors in depth

You will be able to

  • Read a chained traceback and tell "During handling" from "direct cause"
  • Turn an error into your own with raise … from err, and find the original in __cause__ and __context__
  • Hide an unhelpful original error with from None, knowing it is still kept
  1. Warm-up · Activity 1 of 7

    Warm-up from the last lesson: class ConfigError(AppError) and class AppError(Exception). Which clauses catch a ConfigError? Pick all that apply.

    Select all that apply.

  2. Predict · Activity 2 of 7

    Predict before you read on: this stops with a traceback that shows two exceptions. Which line joins them?

    settings = {}
    try:
        port = settings["port"]
    except KeyError:
        raise RuntimeError("config has no port")
  3. Practice · Activity 3 of 7

    Fill in the keyword that makes the KeyError the direct cause of the ConfigError.

    raise ConfigError("port is missing") ____ err
    raise ConfigError("port is missing") err
  4. Practice · Activity 4 of 7

    What does this print?

    try:
        try:
            1 / 0
        except ZeroDivisionError as err:
            raise ValueError("bad ratio") from err
    except ValueError as exc:
        print(type(exc.__cause__).__name__, type(exc.__context__).__name__)
  5. Practice · Activity 5 of 7

    A ValueError is raised inside except KeyError. Match each raise to what its traceback shows.

  6. Brain teaser · Activity 6 of 7

    Brain teaser. from None hides the KeyError. Is it gone? What does this print?

    def lookup(prices, item):
        try:
            return prices[item]
        except KeyError:
            raise LookupError(f"no price for {item}") from None
    
    
    try:
        lookup({}, "tea")
    except LookupError as err:
        print(err.__cause__, repr(err.__context__))
  7. Apply · Activity 7 of 7

    Mini-task. Define PriceError and write parse_price(text), which returns float(text). When float fails, raise PriceError with the message bad price: and the text in repr form, chained to the ValueError with from. Call it with "2.50" and "two", and for the error print the message and repr(err.__cause__).

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

Loading settings: translate, hide, or let through

load_port reads a port from JSON text. It translates invalid JSON into SettingsError with from err, so the parse error stays visible as the cause. A missing key becomes SettingsError with from None, because the KeyError adds nothing. A port that is not a number is not handled at all, and its ValueError reaches the caller unchanged.

main.py

import json


class SettingsError(Exception):
    pass


def load_port(text: str) -> int:
    try:
        data = json.loads(text)
    except json.JSONDecodeError as err:
        raise SettingsError("settings are not valid JSON") from err
    try:
        return int(data["port"])
    except KeyError:
        raise SettingsError("settings have no port") from None


for text in ['{"port": "8080"}', '{"port": 80', '{"host": "a"}', '{"port": "http"}']:
    try:
        port = load_port(text)
    except SettingsError as err:
        cause = type(err.__cause__).__name__
        context = type(err.__context__).__name__
        print(f"{err} | cause: {cause} | context: {context}")
    except ValueError as err:
        print("not a SettingsError:", err)
    else:
        print("port", port)

Run it with

python main.py

Output

port 8080
settings are not valid JSON | cause: JSONDecodeError | context: JSONDecodeError
settings have no port | cause: NoneType | context: KeyError
not a SettingsError: invalid literal for int() with base 10: 'http'
  • With from err, __cause__ and __context__ are both the JSONDecodeError.
  • With from None, __cause__ is None, but the KeyError is still in __context__.
  • except KeyError does not catch the ValueError from int("http"), so it goes through untranslated.
  • The caller catches SettingsError and never needs to know about json or KeyError.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Exercises

Exercise 1 of 2

Keep the cause

parse_price(text) already raises PriceError for text that float cannot read, and for a negative price. Change it so that the "bad price" PriceError is chained to the ValueError with from, so that err.__cause__ is that ValueError. The negative-price error has no cause and needs no change.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    To use the ValueError, give it a name: except ValueError as err:.

  2. Hint 2

    Then add from err at the end of the raise line.

  3. Hint 3

    raise PriceError(f"bad price: {text!r}") from err

Show a solution

One way to solve it. Yours can look different and still pass the checks.

class PriceError(Exception):
    pass


def parse_price(text: str) -> float:
    try:
        value = float(text)
    except ValueError as err:
        raise PriceError(f"bad price: {text!r}") from err
    if value < 0:
        raise PriceError(f"negative price: {value}")
    return value
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

class PriceError(Exception):
    pass


def parse_price(text: str) -> float:
    try:
        value = float(text)
    except ValueError:
        raise PriceError(f"bad price: {text!r}")
    if value < 0:
        raise PriceError(f"negative price: {value}")
    return value

test_main.py

from main import PriceError, parse_price


def test_valid():
    """A valid price comes back as a float"""
    got = parse_price("2.50")
    assert got == 2.5, f"parse_price('2.50') returned {got!r}"


def test_bad_price_message():
    """Text that is not a number raises PriceError with a clear message"""
    try:
        parse_price("two")
    except PriceError as err:
        assert str(err) == "bad price: 'two'", f"the message was {str(err)!r}"
    else:
        assert False, "parse_price('two') returned normally"


def test_bad_price_cause():
    """The ValueError is the direct cause"""
    try:
        parse_price("two")
    except PriceError as err:
        assert isinstance(err.__cause__, ValueError), f"__cause__ is {err.__cause__!r}; use raise ... from err"


def test_negative():
    """A negative price raises PriceError without a cause"""
    try:
        parse_price("-1")
    except PriceError as err:
        assert str(err) == "negative price: -1.0", f"the message was {str(err)!r}"
        assert err.__cause__ is None, f"__cause__ is {err.__cause__!r}, expected None"
    else:
        assert False, "parse_price('-1') returned normally"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Exercise 2 of 2

Hide the lookup, keep the conversion

Write get_setting(settings, key). It returns settings[key]. For a missing key, raise SettingError(f"unknown setting: {key!r}") from None. Then write get_int_setting(settings, key), which returns int(get_setting(settings, key)). If int fails, raise SettingError(f"setting {key!r} is not a whole number") chained to the ValueError with from.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    In get_setting, put return settings[key] in a try, and in except KeyError: raise SettingError(...) from None.

  2. Hint 2

    In get_int_setting, call get_setting outside the try, so its SettingError is not caught again. Put only int(text) inside the try.

  3. Hint 3

    except ValueError as err: raise SettingError(f"setting {key!r} is not a whole number") from err

Show a solution

One way to solve it. Yours can look different and still pass the checks.

class SettingError(Exception):
    pass


def get_setting(settings: dict[str, str], key: str) -> str:
    try:
        return settings[key]
    except KeyError:
        raise SettingError(f"unknown setting: {key!r}") from None


def get_int_setting(settings: dict[str, str], key: str) -> int:
    text = get_setting(settings, key)
    try:
        return int(text)
    except ValueError as err:
        raise SettingError(f"setting {key!r} is not a whole number") from err
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

class SettingError(Exception):
    pass


def get_setting(settings: dict[str, str], key: str) -> str:
    return settings[key]


def get_int_setting(settings: dict[str, str], key: str) -> int:
    return int(get_setting(settings, key))

test_main.py

from main import SettingError, get_int_setting, get_setting

SETTINGS = {"port": "8080", "host": "example.org"}


def catch(f, key):
    try:
        f(SETTINGS, key)
    except SettingError as err:
        return err
    assert False, f"{f.__name__}(settings, {key!r}) returned normally"


def test_values():
    """Existing settings come back"""
    got = get_setting(SETTINGS, "host"), get_int_setting(SETTINGS, "port")
    assert got == ("example.org", 8080), f"got {got!r}"


def test_unknown_hidden():
    """An unknown key raises SettingError from None"""
    err = catch(get_setting, "user")
    assert str(err) == "unknown setting: 'user'", f"the message was {str(err)!r}"
    assert err.__cause__ is None and err.__suppress_context__, "the KeyError should be hidden with from None"
    assert isinstance(err.__context__, KeyError), f"__context__ is {err.__context__!r}, expected the KeyError"


def test_not_a_number_chained():
    """A setting that is not a number is chained to the ValueError"""
    err = catch(get_int_setting, "host")
    assert str(err) == "setting 'host' is not a whole number", f"the message was {str(err)!r}"
    assert isinstance(err.__cause__, ValueError), f"__cause__ is {err.__cause__!r}; use raise ... from err"


def test_unknown_int():
    """get_int_setting reports an unknown key the same way"""
    err = catch(get_int_setting, "user")
    assert str(err) == "unknown setting: 'user'", f"the message was {str(err)!r}"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Common mistakes

A string after from

raise RuntimeError("could not load") from "file missing"

What Python prints

TypeError: exception causes must derive from BaseException

Why, and the fix

The cause must be an exception object (or None), not a message. Catch the original with except OSError as err: and write raise RuntimeError("could not load") from err. To add words, put them in the new exception’s message.

Using the as name after the except clause

try:
    int("x")
except ValueError as err:
    print("bad number")
print(err)

What Python prints

NameError: name 'err' is not defined

Why, and the fix

Python deletes the name after as when the except clause ends. To use the exception later, for example to chain it or report it, assign it to another variable inside the clause: saved = err.

A bug inside the handler

prices = {"tea": 2.5}
try:
    price = prices["coffee"]
except KeyError:
    price = fallbak_price

What Python prints

NameError: name 'fallbak_price' is not defined

Why, and the fix

The traceback shows the KeyError, then During handling of the above exception, another exception occurred:, then this NameError. That joining line means the handler itself failed: here a misspelt name. Fix the handler, not the try block.

Python in the browser: Pyodide 314.0.7, MPL-2.0. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

Automatic chaining: an error while handling an error

When a new exception is raised inside an except clause, Python attaches the exception being handled to it, in its __context__ attribute. The traceback then shows both: first the original, then the line During handling of the above exception, another exception occurred:, then the new one. The last line of the traceback is always the newest exception. If you did not mean to raise in the handler, this points to a bug there, such as a misspelt name.

raise … from err: an intended cause

raise ConfigError("port is missing") from err says: this error is a direct consequence of err. Python stores err in __cause__ as well as __context__, and the traceback joins the two with The above exception was the direct cause of the following exception:. Use it to translate errors: callers catch your own ConfigError instead of a KeyError from deep inside, and a developer still sees where it started. After from comes an exception or None. The name after as exists only inside the except clause; save the object in another variable if you need it later.

from None: hide the noise

raise ValueError("unknown item: cake") from None shows only the new exception. It sets __suppress_context__ to True, so the traceback leaves out the original, but the original stays in __context__ for debugging. Use it when the first error is an internal detail: a KeyError from your own dict lookup tells the reader less than a clear message. Do not use it to hide errors you do not understand; that makes bugs harder to find.

Sources

Last reviewed September 29, 2026