skip to content

Why should a custom exception carry structured attributes instead of only a message string?

level: middleimportance: should knowfreq 55%

answer

  1. Callers act on values, not prose
  2. Handlers should never parse messages
  3. super().__init__ decides what args holds
  4. Rebuilding calls cls(*args)
  5. __str__ for humans, attributes for code

basics

~10 s

Because a handler cannot act on prose without parsing it. Store the failing values as attributes in init, pass them to super().init so they land in args, and format the human-readable sentence in str.

solid answer

~50 s

A caller who catches your error usually needs a value out of it — which SKU was missing, which warehouse, how long the call waited — and the only alternative to attributes is regex-parsing the message, which breaks the first time somebody rewords it. So take the values as `__init__` parameters, assign them to attributes, and pass them to `super().__init__(...)` so they land in `BaseException.args`. That matters beyond neatness: `args` is what the default `__repr__` shows and what `copy` and `pickle` use to rebuild the instance, because reconstruction calls `cls(*exc.args)`. An `__init__` that takes two parameters but forwards a single formatted string leaves `args` with one item, and the rebuild then fails with `TypeError`. Define `__str__` for the human sentence, keep the payload small and picklable, and never store a live resource — or a secret — on the exception.

code

python · 18 lines
python
import pickle


class SkuNotFoundError(Exception):
    def __init__(self, sku, warehouse):
        super().__init__(sku, warehouse)
        self.sku = sku
        self.warehouse = warehouse

    def __str__(self):
        return f"SKU {self.sku!r} is unknown in warehouse {self.warehouse!r}"


exc = SkuNotFoundError("A-1", "north")
print(str(exc))
print(exc.args)
clone = pickle.loads(pickle.dumps(exc))
print(clone.sku, clone.warehouse)

go deeper

for a junior

Know that an exception is a normal object and can carry attributes, and that catching it gives you the instance — so except SkuNotFoundError as exc: exc.sku is available instead of reading the message.

for a middle

Be ready to explain the mechanics of args: what fills it, that the default repr renders it, and that exceptions are rebuilt as cls(*args) — then show the init that forwards its values correctly.

for a senior

Demonstrate the production judgement: payloads that survive a worker round trip, no live resources or secrets on the instance, and attribute names treated as a compatibility surface you cannot rename later.

for a principal

Own the convention across teams — what every error type in the codebase is required to carry so that logging, alerting and retry logic can be written once against attributes rather than against message text.

### The message is for people; the attributes are for code An exception has two audiences. A human reads the last line of a traceback and wants a sentence. A handler three frames up wants a value: the SKU it should skip, the identifier it should retry, the field name it should show next to a form input. Prose serves the first audience and actively obstructs the second, because the only way to get a value back out of a sentence is to parse it — and a message is the least stable part of any library. Design for both audiences at once: attributes carry the data, `__str__` renders the sentence from that data. ```python class SkuNotFoundError(PickListError): def __init__(self, sku, warehouse): super().__init__(sku, warehouse) self.sku = sku self.warehouse = warehouse def __str__(self): return f"SKU {self.sku!r} is unknown in warehouse {self.warehouse!r}" ``` The handler now writes `except SkuNotFoundError as exc: skip(exc.sku)` and never looks at the text. ### What args actually is, and why forwarding matters `BaseException.args` is a tuple holding exactly the positional arguments that reached `BaseException.__init__`. Three things read it: * **The default `__str__`** — with one argument it prints that argument, with several it prints the tuple, with none it prints an empty string. If you define your own `__str__`, this stops mattering for display but not for the rest. * **`__repr__`** — the default repr is `ClassName(*args)`, which is what appears in logs, debuggers and `ExceptionGroup` renderings. An exception whose args are empty reprs as `SkuNotFoundError()` no matter how many attributes it carries. * **Reconstruction** — the pickling protocol for exceptions is essentially `(type(exc), exc.args)`, so rebuilding calls `cls(*args)`. This is how an exception raised in a worker process or thread pool comes back to the caller, and how `copy.copy` duplicates one. That third point is the trap this question is really about. Write an `__init__` taking `(sku, warehouse)` that calls `super().__init__(f"unknown SKU {sku}")`, and `args` is a one-item tuple of prose. Everything looks fine until something round-trips the exception, at which point `cls(*args)` is `SkuNotFoundError("unknown SKU A-1")` — one argument for a two-parameter constructor — and you get a `TypeError` raised *while handling* the original failure, in the code that was supposed to report it. The fix is mechanical: forward the same values you store, and let `__str__` do the formatting. If you need keyword-only extras, give them defaults so `cls(*args)` still constructs. ### Choosing the payload Keep it small, immutable and reconstructible. Identifiers, counts, timeouts, offsets and enum members are ideal. Two things do not belong on an exception instance: * **A live resource.** Attaching the open file, the socket or the reservation handle that failed keeps it alive for as long as anything references the exception — and exceptions are referenced by their traceback frames, which a logging framework or a stored "last error" attribute may hold for a long time. That is how a codebase ends up with a resource left unclosed and no obvious owner. Store the resource's name or id, and close the resource in a `finally` before raising. * **Secrets.** The payload will be logged, put in a repr and shown in a traceback. Pass the key's identifier, never the key. ### Attribute names are public API Once a caller writes `exc.sku`, that name has the same compatibility weight as a function parameter. Choose names you can live with, document them next to the class, and add rather than rename. This is also the argument against dumping a dict onto the exception: `exc.details["sku"]` is an unchecked string key that no type checker or IDE can help with, while `exc.sku` is greppable and typo-proof. ### When a message-only exception is fine Not every error needs a payload. If nothing distinguishes one occurrence from another in a way a handler could act on — an internal invariant that means "this build is broken" — a bare subclass with a good docstring is honest and cheap. The test is the same as everywhere else in error design: *what would a caller do with this value?* If the answer is "print it", the message is enough. If the answer is "branch on it, retry it, or show it in a form", it is an attribute.

  • What exactly breaks when __init__ takes two parameters but forwards one formatted string to super().__init__?
    `args` ends up holding a single string. Since exceptions are rebuilt as `cls(*args)`, `copy.copy` and any pickle round trip — which is how an error crosses a worker boundary — call the two-parameter constructor with one argument and raise `TypeError` while handling the original error. The default repr is also misleading, showing one prose argument instead of the real values.
  • Should the resource that failed — an open file or a connection — be stored on the exception?
    No. The exception is referenced by its traceback and may be held by a logger or a stored last-error slot, so anything attached to it stays alive indefinitely; that is a common source of handles that are never closed. Close the resource in a `finally` and put its name or id on the exception instead.
  • When is a message-only custom exception good enough?
    When no handler could branch on any value in it — typically an internal invariant that means the build or config is broken. A bare subclass with a precise docstring is honest there. The moment a caller wants to know which item, field or key failed, that value becomes an attribute rather than a substring.

A message-only exception is a delivery slip that says "there was a problem with an item" — accurate, and useless to anyone whose job is to fix it.

saying these in an interview costs you the question

  • Handlers that regex-parse the exception message
  • Overriding __init__ without forwarding values to super().__init__
  • Assuming args is filled automatically from instance attributes
  • Putting credentials or tokens into the error message
  • Storing an open file or connection on the exception instance

context