---
title: 'Custom Exceptions'
source: 'https://academia.sh/en/courses/python-fundamentals/custom-exceptions'
course: 'Python Fundamentals'
language: en
updated: '2026-08-17T18:10:27+00:00'
license: 'CC BY-SA 4.0'
---

# Custom Exceptions

A custom exception class's place in the hierarchy decides who catches it: five classes and six catches match in 13 of 30 pairs, the domain root handles seven errors with a single name, the borrowed base catches only three of them, and writing the broad clause first runs the narrow clause zero times.

Every class in the previous lesson came ready-made. `KeyError`, `ValueError`,
`FileNotFoundError` — their lineages were already built, and the measurement could
group them by common ancestor. We saw that catching an intermediate class catches every
leaf beneath it; someone else had done the placing.

But what if what is raised is **the domain's own error**? A measurement device
reporting a value outside the accepted range, a required field missing from a record, a
raw piece of data that cannot be converted to a number. For these, a class has to be
written, and where we hang that class in the lineage is not a matter of style: its place
decides **who will catch it**. That is this lesson's question.

## The Domain's Own Error Is a Type

There is a measurable difference between describing an error with a message string and
describing it with a type. A message is a string; code reading it has to parse the
string, and the parsing breaks the moment the message changes. A type, by contrast, is
exactly what the `except` clause tests directly, and the test is done with `isinstance`.

Writing a custom exception amounts to nothing more than a class definition. The class
can hold the domain data it carries as **attributes** — which field, which value — and
catching code reads these without parsing a message.

The real decision is the base. A **root** class is defined for domain errors, and
concrete errors descend from it; this way a single `except` line covers all of them.
Beyond that, a concrete class can **also** descend from a built-in class that fits its
meaning: an out-of-range value is a `ValueError`, a missing field is a `KeyError`. This
two-base placement looks like a convenience, and its cost is measured.

## Choosing the Root

The measurement builds four domain classes. `MeasurementError` is the root and descends
directly from `Exception`. `OutOfRangeValue` descends from the root **and** from
`ValueError`, `MissingField` from the root **and** from `KeyError`. `BrokenRecord`
descends only from the root. The fifth class, `DeviceError`, does not descend from the
root — it is a separate tree, and it exists to show what the root does not cover.

The root side of the placement looks like this as a tree; because the borrowed bases sit
on the tree's other branches, they are noted at the end of the line:

```text
# illustrative dump, not executed

Exception
├── MeasurementError          domain's root
│   ├── OutOfRangeValue       also ValueError
│   ├── MissingField          also KeyError
│   └── BrokenRecord          root only
└── DeviceError               does not descend from root
```

The measurement's assumptions:

- **EF10** — The lineage chain is read from the class's own resolution order, not
  written by hand; `object` is not counted because every class descends from it.
- **EF11** — A pair "matching" means the catch class covers the raised class through
  `issubclass`; this is exactly the test the `except` clause performs, and it can be
  known in advance at the class level.
- **EF12** — A "foreign catch" is an `except ValueError` or `except KeyError` clause
  written by code unaware of the domain, for its own purposes; a domain class landing
  there is a **leak**.
- **EF13** — The classes are defined purely for placement; none of them add behavior, so
  the only thing measured is **the place in the hierarchy**.

```python
"""Custom exception: the class's place in the hierarchy decides catching behavior."""


class MeasurementError(Exception):
    """The domain's root: every error handling a measurement record descends from here."""

    def __init__(self, field, value=None):
        super().__init__(field)
        self.field = field
        self.value = value

    def __str__(self):
        return f"{type(self).__name__}(field={self.field!r}, value={self.value!r})"


class OutOfRangeValue(MeasurementError, ValueError):
    """The value was read but is outside the field's accepted range."""


class MissingField(MeasurementError, KeyError):
    """The record is missing a required field."""


class BrokenRecord(MeasurementError):
    """The record could not be converted to a number; it has no built-in counterpart."""


class DeviceError(Exception):
    """Does not descend from the domain root: a separate tree."""


CLASSES = (("MeasurementError", MeasurementError), ("OutOfRangeValue", OutOfRangeValue),
            ("MissingField", MissingField), ("BrokenRecord", BrokenRecord),
            ("DeviceError", DeviceError))

CATCHES = (("MeasurementError", MeasurementError), ("OutOfRangeValue", OutOfRangeValue),
               ("ValueError", ValueError), ("KeyError", KeyError),
               ("LookupError", LookupError), ("Exception", Exception))

print("class               place in the hierarchy")
for name, s in CLASSES:
    chain = " < ".join(k.__name__ for k in s.__mro__ if k is not object)
    print(f"  {name:18s}{chain}")

print()
header = "".join(f"{y:<17s}" for y, _ in CATCHES)
print(f"{'raised':<20s}{header}")
for name, s in CLASSES:
    cell = "".join(f"{('matches' if issubclass(s, ys) else '-'):<17s}"
                    for _, ys in CATCHES)
    print(f"  {name:<18s}{cell}")

print()
matches = sum(issubclass(s, ys) for _, s in CLASSES for _, ys in CATCHES)
print(f"classes {len(CLASSES)} | catches {len(CATCHES)} | "
      f"pairs {len(CLASSES) * len(CATCHES)} | matching pairs {matches}")
rooted = sum(issubclass(s, MeasurementError) for _, s in CLASSES)
leaked = sum(issubclass(s, (ValueError, KeyError)) for _, s in CLASSES)
print(f"descend from the domain root {rooted} | leak into a foreign catch {leaked}")
```

```
class               place in the hierarchy
  MeasurementError  MeasurementError < Exception < BaseException
  OutOfRangeValue   OutOfRangeValue < MeasurementError < ValueError < Exception < BaseException
  MissingField      MissingField < MeasurementError < KeyError < LookupError < Exception < BaseException
  BrokenRecord      BrokenRecord < MeasurementError < Exception < BaseException
  DeviceError       DeviceError < Exception < BaseException

raised              MeasurementError OutOfRangeValue  ValueError       KeyError         LookupError      Exception        
  MeasurementError  matches          -                -                -                -                matches          
  OutOfRangeValue   matches          matches          matches          -                -                matches          
  MissingField      matches          -                -                matches          matches          matches          
  BrokenRecord      matches          -                -                -                -                matches          
  DeviceError       -                -                -                -                -                matches          

classes 5 | catches 6 | pairs 30 | matching pairs 13
descend from the domain root 4 | leak into a foreign catch 2
```

## What the Borrowed Base Costs

The upper table gives the placement's result. The `MeasurementError` column matches in
**4** rows — all four classes descending from the root. The `DeviceError` row is empty;
the root does not cover it, because it does not descend from it. Writing the domain's
root is writing the domain's **boundary**.

What is interesting is the `ValueError` and `KeyError` columns. Each matches in **1**
row, and those rows are exactly the classes carrying a borrowed base. `MissingField`
even matches in the `LookupError` column too: because it descends from `KeyError`, it
also inherited that class's ancestor. A single placement decision put the class within
the reach of **three separate foreign clauses**.

The bottom row counts this: **2** of the four domain classes leak into a foreign catch.
This means the `except ValueError` clause code unaware of the domain writes for its
`int()` call will also catch an `OutOfRangeValue` object — a measurement error mistaken
for and handled as a conversion error. A borrowed base gives the class **meaning**, and
in exchange, **scope**.

**13** of thirty pairs match. **5** of these come from the `Exception` column; every
class descends from it, and that column is not distinctive. The distinguishing columns
are the root and the borrowed bases.

## Catch Forms and Clause Order

The second measurement runs the classes. Twelve measurement records are processed; part
of them are valid, part produce a domain error, and one produces a **program defect** —
the coefficient field is written with the wrong type, and the multiplication raises
`TypeError`. Four catch forms are tried, and three numbers are read for each: how many
names were written, how many domain errors were handled, how many program defects were
swallowed.

- **EF14** — Each of the twelve records is really processed; the result class is not
  labeled by hand, it is read from the raised object.
- **EF15** — The oracle is the setup: which record is a domain error and which is a
  program defect is known from how the records were built.
- **EF16** — "Handled" means the caught object is an instance of `MeasurementError`;
  "swallowed defect" means an object that was caught but does not descend from the
  domain root.
- **EF17** — "Escaped" is the count of records that do not fall into the form's clause
  and pass to the next clause.
- **EF18** — In the clause-order measurement, two blocks write the **same** two classes
  and only change the order; there is no other difference.

```python
"""Catch forms: how many names are written, how many domain errors handled, how many defects swallowed."""


class MeasurementError(Exception):
    def __init__(self, field, value=None):
        super().__init__(field)
        self.field = field
        self.value = value

    def __str__(self):
        return f"{type(self).__name__}(field={self.field!r}, value={self.value!r})"


class OutOfRangeValue(MeasurementError, ValueError):
    pass


class MissingField(MeasurementError, KeyError):
    pass


class BrokenRecord(MeasurementError):
    pass


LOWER_BOUND, UPPER_BOUND = -50.0, 60.0

RECORDS = (
    {"value": "12.5", "coefficient": 2},
    {"value": "-3", "coefficient": 1},
    {"value": "99", "coefficient": 1},
    {"coefficient": 1},
    {"value": "north", "coefficient": 1},
    {"value": "0", "coefficient": 3},
    {"value": "-80", "coefficient": 1},
    {"value": None, "coefficient": 1},
    {"value": "45", "coefficient": "2"},
    {"coefficient": 5},
    {"value": "60", "coefficient": 1},
    {"value": "60.1", "coefficient": 1},
)


def process(record):
    if "value" not in record:
        raise MissingField("value")
    raw = record["value"]
    try:
        number = float(raw)
    except (TypeError, ValueError):
        raise BrokenRecord("value", raw) from None
    if not LOWER_BOUND <= number <= UPPER_BOUND:
        raise OutOfRangeValue("value", number)
    return record["coefficient"] * number


def class_name(record):
    try:
        process(record)
    except BaseException as e:
        return type(e).__name__
    return "-"


results = [class_name(r) for r in RECORDS]
print("record results:", ", ".join(f"{i}:{s}" for i, s in enumerate(results, 1)))
domain_count = sum(s in ("OutOfRangeValue", "MissingField", "BrokenRecord") for s in results)
defect_count = sum(s == "TypeError" for s in results)
print(f"records {len(RECORDS)} | successful {results.count('-')} | "
      f"domain error {domain_count} | program defect {defect_count}")

FORMS = (
    ("three names", (OutOfRangeValue, MissingField, BrokenRecord), 3),
    ("domain root", (MeasurementError,), 1),
    ("borrowed base", (ValueError,), 1),
    ("broad", (Exception,), 1),
)

print()
print(f"{'catch form':<17s}{'names written':>14s}{'handled':>12s}"
      f"{'swallowed defect':>18s}{'escaped':>12s}")
for name, cls, count in FORMS:
    handled = swallowed = escaped = 0
    for r in RECORDS:
        try:
            process(r)
        except cls as e:
            if isinstance(e, MeasurementError):
                handled += 1
            else:
                swallowed += 1
        except Exception:
            escaped += 1
    print(f"  {name:<15s}{count:>14d}{handled:>12d}{swallowed:>18d}{escaped:>12d}")

print()
root_first = {"MeasurementError": 0, "OutOfRangeValue": 0}
for r in RECORDS:
    try:
        process(r)
    except MeasurementError:
        root_first["MeasurementError"] += 1
    except OutOfRangeValue:
        root_first["OutOfRangeValue"] += 1
    except Exception:
        pass

narrow_first = {"OutOfRangeValue": 0, "MeasurementError": 0}
for r in RECORDS:
    try:
        process(r)
    except OutOfRangeValue:
        narrow_first["OutOfRangeValue"] += 1
    except MeasurementError:
        narrow_first["MeasurementError"] += 1
    except Exception:
        pass

print("clause order    first clause              second clause")
print(f"  root first    MeasurementError {root_first['MeasurementError']:<13d}"
      f" OutOfRangeValue {root_first['OutOfRangeValue']}")
print(f"  narrow first  OutOfRangeValue {narrow_first['OutOfRangeValue']:<9d}"
      f" MeasurementError {narrow_first['MeasurementError']}")

print()
print("domain errors reported through their attributes:")
for i, r in enumerate(RECORDS, 1):
    try:
        process(r)
    except MeasurementError as e:
        print(f"  record {i:2d}: {e} -> field {e.field!r}, value {e.value!r}")
    except Exception:
        pass
```

```
record results: 1:-, 2:-, 3:OutOfRangeValue, 4:MissingField, 5:BrokenRecord, 6:-, 7:OutOfRangeValue, 8:BrokenRecord, 9:TypeError, 10:MissingField, 11:-, 12:OutOfRangeValue
records 12 | successful 4 | domain error 7 | program defect 1

catch form        names written     handled  swallowed defect     escaped
  three names                 3           7                 0           1
  domain root                 1           7                 0           1
  borrowed base               1           3                 0           5
  broad                       1           7                 1           0

clause order    first clause              second clause
  root first    MeasurementError 7             OutOfRangeValue 0
  narrow first  OutOfRangeValue 3         MeasurementError 4

domain errors reported through their attributes:
  record  3: OutOfRangeValue(field='value', value=99.0) -> field 'value', value 99.0
  record  4: MissingField(field='value', value=None) -> field 'value', value None
  record  5: BrokenRecord(field='value', value='north') -> field 'value', value 'north'
  record  7: OutOfRangeValue(field='value', value=-80.0) -> field 'value', value -80.0
  record  8: BrokenRecord(field='value', value=None) -> field 'value', value None
  record 10: MissingField(field='value', value=None) -> field 'value', value None
  record 12: OutOfRangeValue(field='value', value=60.1) -> field 'value', value 60.1
```

## Reading the Numbers

**4** of the twelve records are successful, **7** are domain errors, **1** is a program
defect. Four forms split this distribution in different ways.

**Three names** and **domain root** give the same result: **7** handled, **0**
swallowed defects, **1** escaped. The two are indistinguishable — but one writes **3**
names, the other **1**. This is exactly what the root pays for: as the domain grows, new
concrete classes will descend from the root, and no clause written as `except
MeasurementError` will need to change. A clause counting three names has to be updated
with every new class.

**Borrowed base** handles only **3** errors and lets **5** records escape. Writing
`except ValueError` catches `OutOfRangeValue`, but `MissingField` and `BrokenRecord` do
not fall into it. A borrowed base is not a **group name**; each concrete class's own
borrowed base is separate, and there is no shared one among them. The domain's group
name is only the root.

**Broad** catching lets **0** records escape — and swallows **1** program defect. The
record whose coefficient field was written with the wrong type is handled as if it were
a domain error. The previous lesson's finding takes concrete shape here: what hides the
defect is not the defect itself, it is the clause that covers it.

The **clause order** table gives the sharpest number. When the root is written first,
the `MeasurementError` clause runs **7** times, the `OutOfRangeValue` clause **0** times.
The second clause is written, its syntax is correct, it raises no warning, and it **never
runs** — because the first matching clause wins, and the root already covers everything.
When the order is reversed, the narrow clause gets **3**, the root clause **4**; the
total is still **7**. Catch clauses are written **narrow to broad**; the reverse
silently produces dead code.

The last dump shows what the attribute earns. Every line gives the field name and the
value **without parsing a message**; code reading the difference between `-80.0` and
`60.1` works with a number, not a string.

## Translation at the Boundary

The root's second job does not show up in the table, but it stands in the measurement's
code. The `process` function tries to convert the raw value with `float`, and that call
can raise either `ValueError` or `TypeError` depending on the data's shape. Neither of
these two classes is the domain's own class; both are the conversion layer's classes.
The code catches them and raises `BrokenRecord` in their place:

`except (TypeError, ValueError): raise BrokenRecord("value", raw) from None`

These three lines are a **boundary translation**. Code that knows what happened inside
speaks the domain's language to the outside. The eighth record in the output is the
result of this: the `value` field is not a number, it is not a value at all, and `float`
produces a `TypeError` from it — but the class that escapes is `BrokenRecord`, and the
`except MeasurementError` clause catches it. Without the translation, that record would
fall into the same class as the one set apart as a "program defect" in the previous
section, and the two could not be told apart.

The previous lesson's chain measurement also gives the cost of the `from None` used
here: because the context is suppressed, someone looking from outside cannot see the
`TypeError`. This is a deliberate choice — the conversion detail does not leak outside
the domain layer. If keeping the detail is wanted, `from` is written and the chain
becomes two; the decision is which side of the boundary gets the information.

Translation has a limit too. The `TypeError` in the coefficient field was not
translated, because that error does not come from the conversion layer, it comes from
the code that **builds** the record. Translating a program defect into the domain's
class would make it look like something that can be handled.

## Summary

- A custom exception class's place in the hierarchy is not a matter of style; it decides
  **who will catch it**.
- Five classes and six catches produce **30** pairs, **13** of which match; the domain
  root covers **4** classes while it does not cover the class that does not descend
  from it.
- A borrowed base gives the class meaning and, in exchange, scope: **2** of four domain
  classes leak into a foreign `except ValueError` or `except KeyError` clause.
- The domain root handles **7** errors with **1** name; the clause counting three names
  gives the same result with **3** names, the borrowed base catches only **3** errors,
  the broad clause swallows **1** program defect.
- Clause order must go narrow to broad: when the root is written first, the narrow
  clause runs **0** times and silently becomes dead code.

## Next Step

This lesson's twelve records sat ready in memory; the `value` field was already a
string, and the only problem was converting it to a number. When records come from a
file, one more layer gets inserted: what sits in a file is neither a string nor a
number, it is **bytes**. Converting bytes to a string is an encoding decision, and if
that decision is wrong, the error never reaches the domain layer at all — it shows up
while reading. The next lesson measures what the same bytes give in text mode versus
binary mode, and exactly where a wrong encoding produces its exception.
