Why does a custom exception fail to unpickle with a TypeError about missing arguments?
answer
- Reconstruction calls the class again
- Only args are re-supplied as parameters
- super().__init__ decides what args holds
- Instance dict restores after the call
- Override __reduce__ for awkward constructors
basics
~20 sBecause BaseException.__reduce__ stores only the class and args, and unpickling reconstructs the object by calling the class with those args. If your __init__ signature does not match what you passed to super().__init__, that call fails.
solid answer
~40 sExceptions get their pickling behaviour from `BaseException.__reduce__`, which returns the class plus `self.args`, and the instance `__dict__` as extra state when it is non-empty. Unpickling calls `cls(*args)` and then restores the state dict. So if your `__init__` takes two required parameters but you passed a single formatted message to `super().__init__`, `args` has one element and the reconstruct call raises `TypeError: missing 1 required positional argument`. The fix is to make `args` mirror the signature -- pass every constructor parameter to `super().__init__(...)` -- or to override `__reduce__` and return the arguments you want re-supplied. A third option is defaults on every parameter. Note the attributes themselves do survive via the state dict, but only if the reconstruct call succeeds first.
code
python · 24 linesimport pickle
class Broken(Exception):
def __init__(self, service, status):
super().__init__(f"{service} returned {status}")
self.service = service
self.status = status
class Fixed(Exception):
def __init__(self, service, status):
super().__init__(service, status)
self.service = service
self.status = status
try:
pickle.loads(pickle.dumps(Broken("metrics-7", 503)))
except TypeError as exc:
print("broken:", exc)
back = pickle.loads(pickle.dumps(Fixed("metrics-7", 503)))
print("fixed:", back.service, back.status)go deeper
Know that reconstructing a pickled exception calls its class again with the stored args. Passing every constructor parameter to super().__init__(...) keeps that call valid.
Explain the two-step reconstruction -- call the class with args, then apply the instance dictionary -- and why a mismatch between args and the __init__ signature raises TypeError before any attribute is restored.
Recognise the signature from a parent-side TypeError naming a worker's constructor, choose between fixing super().__init__ and overriding __reduce__, and require a pickle round-trip test on any exception a worker can raise.
Set the contract: exceptions crossing process boundaries carry plain data only, are round-trip tested, and are versioned deliberately, so a failure report never turns into a serialization incident of its own.
**How an exception pickles.** `BaseException` defines `__reduce__`, and every exception inherits it unless it overrides it. It returns a two- or three-item tuple: the class, `self.args`, and -- only when the instance dictionary is non-empty -- that dictionary as state. Unpickling then does two things in order: it *calls* the class with the stored args as positional arguments, and it updates the new object's `__dict__` with the stored state. Both halves matter, and the first is where custom exceptions break. **Where `args` comes from.** `args` is whatever was passed to `BaseException.__init__` -- in practice, whatever you handed to `super().__init__(...)`. The overwhelmingly common custom-exception idiom builds a nice message and passes only that: ```python class ScrapeFailed(Exception): def __init__(self, service, status): super().__init__(f"{service} returned {status}") self.service = service self.status = status ``` That object is perfect in-process: `str(exc)` reads well, and `exc.service` is there. But its `args` is a one-tuple containing the formatted string, while `__init__` demands two positional parameters. Reconstructing it means calling `ScrapeFailed("metrics-7 returned 503")`, which raises `TypeError: ScrapeFailed.__init__() missing 1 required positional argument: 'status'`. The exception cannot cross a process boundary at all, and the error the parent finally sees is a `TypeError` about a constructor -- not the scrape failure you were trying to report. **The asymmetry that confuses people.** The attributes are not the problem. `self.service` and `self.status` live in the instance `__dict__`, which `__reduce__` does carry as state, so they would be restored faithfully -- if the object could be constructed in the first place. This is why the bug reads as nonsense at first: the data is all in the pickle, and the failure is in the call that must happen before the data is applied. **Three fixes, in order of preference.** 1. *Make `args` mirror the signature.* Pass every constructor parameter through: `super().__init__(service, status)`. Then `args == (service, status)`, the reconstruct call matches, and you get the message back by defining `__str__` instead of pre-formatting. This is the smallest change and needs no pickle knowledge from the next reader. 2. *Override `__reduce__`.* Return `(type(self), (self.service, self.status))` explicitly. Useful when you cannot change what goes into `args` -- for instance when a message must stay in `args[0]` for compatibility with existing handlers -- and it is the standard tool for exceptions with genuinely awkward constructors. 3. *Give every parameter a default.* `def __init__(self, service=None, status=None)` makes the mismatched call succeed and the state dict then repairs the attributes. It works, but it silently permits half-built exceptions and it hides the real intent, so treat it as a last resort. **The second gate: is the payload itself picklable?** Even with a correct signature, every value in `args` and in the instance dict has to serialize. Attaching a live handle to an exception -- a lock, an open socket, a database connection, a file object -- raises `TypeError: cannot pickle '_thread.lock' object` in the *child*, at send time, so once again the real failure disappears and is replaced by a serialization error. Exceptions that cross processes should carry plain data: strings, numbers, tuples, dicts of those. If you want the offending object's identity, carry its repr or its identifier, not the object. **How to catch this before production.** It is trivially testable without any concurrency at all: `pickle.loads(pickle.dumps(exc))` in a unit test, asserting the type, the message and each attribute survive. Any exception class intended to be raised inside a worker deserves exactly that one-line round-trip test. Debugging it later, from a parent-side `TypeError` naming a constructor you did not think was being called, is far more expensive than writing it. **One trap inside the second fix.** If you override `__reduce__` and return a two-item tuple -- the class and the arguments -- you have replaced the inherited behaviour entirely, including the part that carried the instance dictionary. Anything that is not re-created by the constructor call is then lost: an attribute set on the exception after it was raised, a retry counter incremented by a handler, a note attached on the way up. Return a three-item tuple, adding `self.__dict__` as state, whenever the instance may carry more than the constructor puts there. It is a small detail with a nasty signature: the exception arrives looking complete, and only the field added late is missing.
- If the instance `__dict__` is pickled anyway, why does the attribute mismatch matter?Because the state dict is applied *after* the object exists. Unpickling first calls the class with the stored `args`; only if that call returns does it update `__dict__`. A signature mismatch aborts at the call, so the attributes never get their chance. The data is present in the pickle and still unusable.
- When would you override `__reduce__` on an exception rather than change `super().__init__`?When `args` has to keep a particular shape for reasons outside pickling -- handlers or logs that read `args[0]` as the message, or a base class you do not control. Then return `(type(self), (self.service, self.status))` so reconstruction gets real constructor arguments while `args` keeps its existing contents. Add `self.__dict__` as a third item, though: a two-item `__reduce__` replaces the inherited behaviour and drops any attribute the constructor does not re-create.
- What happens if an exception carries a lock or an open socket as an attribute?Serialization fails in the sending process with a `TypeError` naming the unpicklable object -- for a `threading.Lock`, `cannot pickle '_thread.lock' object`. The real failure never reaches the parent and is replaced by a serialization error. Exceptions meant to cross a process boundary should carry only plain data; keep an identifier or a repr instead of the live handle.
- How do you catch this class of bug before it reaches production?A one-line unit test per exception class: round-trip it with `pickle.loads(pickle.dumps(exc))` and assert the type, the string form and each attribute survive. It needs no processes, no pool and no fixtures, and it fails loudly on exactly the signature and payload problems that otherwise only appear under load in a worker.
saying these in an interview costs you the question
- Thinks unpickling copies memory without calling __init__
- Believes attributes are lost when the real failure is construction
- Pre-formats one message and ignores what args holds
- Attaches live handles like locks or sockets to exceptions
- Adds default parameters to silence the error without understanding it
- Never round-trip tests an exception meant for a worker