Python — Unpickling Fails After a Class Refactor
Resolve pickle AttributeError Can't get attribute and __reduce__ errors after renaming classes or moving modules, and keep pickles loadable with aliasing.
Symptoms
- AttributeError: Can't get attribute 'Invoice' on <module 'app.billing' from ...> during unpickle
- TypeError: cannot pickle 'X' object or __reduce__ complaints after a dependency upgrade
- Historical artifacts in object storage or a Redis cache stop loading after a deploy that renamed or moved classes
Possible causes
- pickle records the fully qualified class path by module and name, and the refactor changed either part
- A dataclass gained or lost fields, so old pickles construct objects whose state no longer matches __init__
- A class moved into a package with lazy imports, and the lazy attribute is absent at unpickle time
- The pickled object graph captured a lambda or local object that no longer exists anywhere
Diagnosis steps
Read the missing attribute name
python -c "import pickle; pickle.load(open('old.pkl','rb'))" 2>&1 | tail -3Interpretation
The error names the exact old path (module plus class); mapping it to the new location defines the alias you need.
Inspect what the pickle references
python -c "import pickletools; pickletools.dis(open('old.pkl','rb'))" | grep -E "GLOBAL|STACK_GLOBAL" | headInterpretation
The disassembly lists every module and class the artifact embeds — the full alias manifest.
Check the migration surface
git log --oneline --follow -n 5 -- app/models/invoice.pyInterpretation
The refactor history shows which renames and moves the stored pickles predate.
Root cause
pickle serializes objects by reference to their import path: module string plus class name, then instance state. When a refactor renames the class, moves the module, or changes fields, unpickling resolves the old path, finds nothing, and raises AttributeError — the bytes are intact but the namespace they point into is gone.
Fix
Bridge old paths with module-level aliases or a custom Unpickler that remaps old names to new classes, make __getstate__ and __setstate__ tolerant of missing or extra keys, and migrate storage to a schema-tolerant format (JSON, msgpack, protobuf) once the emergency bridge is stable.
Risk note
Unpickling data from untrusted sources executes arbitrary code via the module path. Keep a strict allowlist in any custom Unpickler.
Verify the fix
Load representative artifacts from before and after the refactor through the same loader, asserting field-level equality with their original contents.
Symptom walkthrough
The error is a path lookup failure dressed as a data problem:
AttributeError: Can't get attribute 'Invoice' on
<module 'app.billing' from '/app/app/billing.py'>
pickle stores no class definition — only module string, class name, and instance state. Disassemble an artifact to see every reference it embeds:
python -m pickletools old.pkl | grep -A2 GLOBAL
# 0: \x8c GLOBAL 'app.billing' 'Invoice'
That listing is the complete manifest of paths your refactor has to bridge. The failure always follows a deploy, never a data change, which separates it from corruption.
Fix in practice
The immediate bridge is an alias or a remapping unpickler:
import sys, pickle
from app.models import Invoice # new location
sys.modules['app.billing'] = type(sys)('app.billing')
sys.modules['app.billing'].Invoice = Invoice
obj = pickle.loads(blob) # old path resolves to new class
For a controlled surface, subclass the unpickler instead of faking modules:
class MigratingUnpickler(pickle.Unpickler):
ALIASES = {("app.billing", "Invoice"): Invoice}
def find_class(self, module, name):
if (module, name) in self.ALIASES:
return self.ALIASES[(module, name)]
if module.startswith("app."):
raise pickle.UnpicklingError(f"untrusted module: {module}")
return super().find_class(module, name)
For field drift, tolerate old shapes explicitly:
def __setstate__(self, state):
state.setdefault("currency", "USD") # added field
state.pop("legacy_vat", None) # removed field
self.__dict__.update(state)
Then plan the exit: rewrite artifacts to JSON or msgpack on read, and stop producing new pickles for anything that outlives a deploy.