skip to content

Why would __setstate__ never run when a cached feature-flag object is unpickled?

level: seniorimportance: should knowfreq 25%

answer

  1. The restore hook did not run at all
  2. Look at what the dump side returned
  3. One particular value means no state
  4. Empty is not the same as absent
  5. None skips the step entirely

basics

~20 s

Because the stored state was None. CPython 3.14 applies a state-restoring step only when there is state to apply, so a getstate returning None means setstate is skipped entirely and the rebuild silently never happens.

solid answer

~50 s

The unpickler only performs the state-application step when the pickle actually carries state. If `__getstate__` returned `None` -- explicitly, or via the inherited `object.__getstate__` for an instance whose `__dict__` happens to be empty -- there is nothing to apply, and `__setstate__` is never called. Every rebuild that hook was supposed to do, such as recompiling the flag predicates the class deliberately left out of the state, silently does not happen. Nothing raises at load time; the object comes back missing an attribute, and if the flag-evaluation path wraps its lookup in a broad `except Exception`, the resulting `AttributeError` is swallowed and every flag reads as off for the whole 11-person team behind that cache. The fixes are to never use `None` as "nothing to store", to make the restore total rather than conditional, and to assert on the round trip in a test.

code

python · 15 lines
python
import pickle

class RuleCache:
    def __init__(self, rules):
        self.rules = rules

    def __getstate__(self):
        return None

    def __setstate__(self, state):
        self.rules = {}
        self.compiled = True

restored = pickle.loads(pickle.dumps(RuleCache({"beta": True})))
print(hasattr(restored, "compiled"))

go deeper

for a junior

Take away one rule: a restore hook is not guaranteed to run, so an unpickled object can come back missing attributes. If something looks blank after loading, check what the dump side actually stored.

for a middle

Explain the two-step nature of unpickling -- allocate, then apply state -- and that a None state means the second step, and therefore __setstate__, is skipped. Know that an empty dict behaves differently from None here.

for a senior

Demonstrate the diagnosis and the durable fix: inspect what __getstate__ returns on a live instance, make derived state lazy so a skipped restore is harmless, and treat a broad exception handler without logging as the reason a load-time bug became a behavioural one.

for a principal

Own the standard: which objects may be persisted as pickles, how state formats are versioned as classes evolve, and where blanket exception handling is allowed to hide a defect. Argue for the round-trip test as a release gate on any class with hand-written state hooks.

## The mechanism A pickle for an instance is built in two halves: make a blank object, then apply state to it. The second half is emitted only when there is state. If the value produced on the dump side is `None`, the stream carries no state at all, so on the load side there is nothing to hand to `__setstate__` -- and the unpickler does not call it. Verified on CPython 3.14: `None` skips the hook, while an empty dict `{}` still calls it with `{}`. The documentation describes the skip in looser terms than the implementation, so do not build anything on the difference between an empty container and `None`; treat `None` as the one value that means "no state", and never use it as a sentinel. ```python import pickle class RuleCache: def __init__(self, rules): self.rules = rules def __getstate__(self): return None # "nothing worth storing" def __setstate__(self, state): self.rules = {} self.compiled = True # never runs restored = pickle.loads(pickle.dumps(RuleCache({"beta": True}))) print(hasattr(restored, "compiled")) # False ``` ## How it reaches production The realistic route is not somebody typing `return None` on purpose. It is a class whose entire useful state is derived and therefore deliberately excluded: a feature-flag cache that holds only compiled rule predicates, refreshed from a source of truth, with `__getstate__` written to drop them and `__setstate__` written to rebuild them. Drop everything and the state becomes empty, and if the code takes the extra step of returning `None` for tidiness -- or if the instance's `__dict__` is genuinely empty so the inherited default returns `None` -- the rebuild hook is skipped. The restored object is a blank instance of the right class. It passes an `isinstance` check. It fails only when something touches the attribute that was supposed to be rebuilt. ## Why it stays hidden Flag-evaluation code is defensive by convention, because nobody wants a flag lookup to take down a request. A `try: ... except Exception: return False` around the evaluation is the normal shape. It turns the `AttributeError` from the missing compiled rules into a uniform "flag is off", so the service does not crash, no error is logged loudly, and the symptom is behavioural: an 11-person pilot team that was supposed to see the new checkout flow sees the old one, for as long as it takes someone to correlate the change with a restart that loaded from the cache file. A swallowed exception is what converts a crisp load-time bug into a slow behavioural mystery. ## Fixing it properly * **Never return `None` to mean "nothing to save".** If the restore hook must run, give it something truthy and unambiguous, such as a small dict carrying a state version number. * **Do not make correctness depend on the hook running.** Rebuild derived state lazily, behind a property or a `__getattr__` that constructs it on first access. Then a skipped restore costs nothing and the object is correct however it was constructed. * **Make the restore total.** `__setstate__` should tolerate a state written by an older version of the class -- read fields with a mapping lookup that supplies a default rather than indexing blindly -- and should end with every invariant the constructor would have established. * **Assert the round trip in a test.** One `pickle.loads(pickle.dumps(obj))` plus an assertion on the rebuilt attribute would have caught this before it shipped, and it is the test most often missing when these hooks are hand-written. * **Do not swallow every exception around the flag path.** Catching broadly is defensible for availability; doing it without logging the exception is what made this invisible. Catch, return the safe default, and record what was caught. ## Diagnosing it live When the symptom is "restored object behaves as if empty", check the dump side first, not the load side: call `__getstate__` on a live instance and look at what it returns. `None` there explains everything at once. Comparing `vars()` of an original and a restored instance is the fastest confirmation, and it also catches the neighbouring bug in which the state was stored fine but the restore hook was never defined, so the attributes merged into `__dict__` without any of the rebuilding work happening.

  • How would you make the restored object correct even if __setstate__ never ran?
    Stop making correctness depend on the hook. Build the derived data lazily -- a property or a `__getattr__` that compiles the rules on first access and caches the result -- so a blank restored instance repairs itself on use. The restore hook then becomes an optimisation rather than a load-bearing step.
  • An old pickle is missing a field the class gained last month. How should __setstate__ handle it?
    Read the state as a mapping with defaults rather than indexing it, so a missing key yields a sensible value instead of a `KeyError`, and finish by establishing every invariant the constructor guarantees. Storing an explicit version number in the state makes the migration decision explicit instead of implicit in the shape of the data.
  • What single test would have caught this before release?
    A round-trip assertion: pickle the object, load it back, and assert that the rebuilt attribute exists and that the instance satisfies the invariants the constructor guarantees. It runs in milliseconds, needs no infrastructure, and is exactly the test that hand-written state hooks tend to ship without.

saying these in an interview costs you the question

  • Assumes __setstate__ always runs on unpickling
  • Uses None as a sentinel for empty state
  • Blames the load side without checking what was dumped
  • Catches every exception around the flag lookup without logging
  • Thinks a missing attribute must raise at load time
  • Relies on the restore hook to establish core invariants

context