← Debugging Guides

阅读中文版本

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.

intermediatedebuggingpythonpickleserialization

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 -3

Interpretation

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" | head

Interpretation

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.py

Interpretation

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.