pyguides

Customising pickle with copyreg

The copyreg module gives you precise control over customising pickle behaviour for your classes. Rather than relying on pickle’s default object introspection, you register reducer functions that tell pickle exactly how to serialise and reconstruct each type. By registering pickling functions with copyreg, pickle knows exactly how to handle your custom classes, making them portable across Python versions and implementations.

Pickle usually works by walking an object’s internal dictionary and saving whatever it finds. That approach breaks down when your object holds things pickle cannot reach: file handles, network sockets, lambdas defined inside a function body, or state that only makes sense at runtime. The copyreg module handles all of these cases by letting you specify a reconstruction recipe that pickle follows instead of guessing.

The case for copyreg

By default, pickle can serialize most Python objects by storing their __dict__. However, this approach has limitations:

  • It may not work correctly for objects with complex internal state
  • It can be inefficient for objects that contain references to functions or other unpicklable objects
  • It doesn’t preserve class methods or custom attributes set outside __init__

The copyreg module lets you define exactly how your objects should be reduced to their fundamental components and rebuilt.

A reducer is simply a callable that accepts an object and returns a reconstruction recipe: a tuple containing a constructor function and the arguments needed to invoke it. When pickle encounters a registered type, it calls your reducer instead of its default introspection logic, giving you full control over what gets serialized and how the object is recreated on the other side.

Registering a reducer function

The core of copyreg is the register function, which takes a class and a reducer:

import copyreg
import pickle

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    
    def __repr__(self):
        return f"Point({self.x}, {self.y})"

# Register a reducer function
def reduce_point(point):
    # Return constructor and arguments needed to recreate
    return (Point, (point.x, point.y))

copyreg.pickle(Point, reduce_point)

# Now Point objects can be pickled
p = Point(3, 4)
data = pickle.dumps(p)
unpickled = pickle.loads(data)
print(unpickled)  # Point(3, 4)

The reducer function must return a tuple of (constructor, args) where constructor is a callable that can recreate the object from args.

The registration step binds your reducer to the class permanently within the current process. Once registered, every call to pickle.dumps on an instance of that class goes through your reducer, not pickle’s default mechanism. This indirection is what makes copyreg so flexible: you can change how an object is serialized without touching its source code.

External reducers versus reduce

While you can define __reduce__ directly on a class, using copyreg offers several advantages:

  • Separation of concerns: The serialization logic lives outside the class
  • Version compatibility: You can change the reducer without modifying the class
  • Inheritance: Subclasses automatically inherit the reducer unless overridden

Using copyreg instead of __reduce__ also lets you register reducers for classes you do not own. If you are working with a third-party library whose objects need custom pickling, defining __reduce__ on their class is intrusive and risks breaking on library updates. With copyreg, you write the reducer in your own code and register it at import time. The library class stays untouched, and pickle routes through your reducer transparently.

Similarly, if two independent modules both need to pickle the same class differently for different use cases, copyreg lets each module register its own reducer. The last registration wins, so import order matters. In practice, this means you should register reducers early, typically at module level during import, to avoid surprises from downstream code overwriting your registration.

Handling complex objects

For objects with state that isn’t easily captured in __init__ arguments, you can use __getstate__ and __setstate__ alongside copyreg:

import copyreg
import pickle

class DataProcessor:
    def __init__(self, name):
        self.name = name
        self.cache = {}  # Not picklable by default
    
    def __getstate__(self):
        # Return state to pickle (exclude unpicklable items)
        return {"name": self.name}
    
    def __setstate__(self, state):
        # Restore state
        self.name = state["name"]
        self.cache = {}  # Recreate unpicklable attributes
    
    def __repr__(self):
        return f"DataProcessor({self.name}, cache_size={len(self.cache)})"

def reduce_processor(processor):
    return (
        DataProcessor,
        (processor.name,),
        processor.__getstate__()
    )

copyreg.pickle(DataProcessor, reduce_processor)

# Test it
processor = DataProcessor("my_processor")
processor.cache["key"] = "value"

data = pickle.dumps(processor)
unpickled = pickle.loads(data)
print(unpickled)  # DataProcessor(my_processor, cache_size=0)

The key insight here is that copyreg doesn’t just serialize object state; it lets you explicitly control how objects are reconstructed, which is essential when some attributes cannot be serialized directly. The reducer function decides the constructor arguments, while __getstate__ and __setstate__ handle any extra state that doesn’t fit into the constructor. Together they give you complete control over the pickling lifecycle without modifying the class itself. This approach is particularly useful for objects that hold file handles, network connections, or other non-serializable resources.

Pickling functions and classes

By default, pickle can reference functions by name if they are available in the module’s global scope. However, for lambda functions or dynamically created classes, you need to register reducers:

import copyreg
import pickle

# This won't work with lambdas by default:
# point = Point(1, 2)  # where Point is defined inline

# Instead, define at module level and register:
class Vector:
    def __init__(self, x, y, z):
        self.coords = (x, y, z)
    
    def __repr__(self):
        return f"Vector{self.coords}"

def reduce_vector(v):
    return (Vector, v.coords)

copyreg.pickle(Vector, reduce_vector)

v = Vector(1, 2, 3)
print(pickle.loads(pickle.dumps(v)))  # Vector(1, 2, 3)

The reducer for Vector is straightforward because the constructor already accepts all necessary state as separate arguments. When designing your own reducers, aim for this pattern: make the constructor serve as the natural reconstruction point. If your objects require multi-step setup or have circular references, you may need a more elaborate reducer that handles those edge cases explicitly. The Vector example also demonstrates why module-level class definitions are important: dynamically created classes lack a stable import path and will fail during unpickling unless you register them with copyreg.

Versioning with constructors

You can use reducers to handle version migration by creating constructors that accept both old and new argument formats:

import copyreg
import pickle

class Config:
    def __init__(self, setting, value=None):
        # Support both old format (setting only) and new (setting, value)
        if value is None:
            # Old format: setting was a dict
            if isinstance(setting, dict):
                self.settings = setting
            else:
                self.settings = {"value": setting}
        else:
            # New format: explicit setting and value
            self.settings = {setting: value}
    
    def __repr__(self):
        return f"Config({self.settings})"

def reduce_config(config):
    # Always save in new format
    settings = config.settings
    if len(settings) == 1:
        key, value = next(iter(settings.items()))
        return (Config, (key, value))
    else:
        return (Config, (settings,))

copyreg.pickle(Config, reduce_config)

# New-style object
c1 = Config("debug", True)
data1 = pickle.dumps(c1)
print(pickle.loads(data1))  # Config({'debug': True})

# Old-style object (if you had one)
c2 = Config({"theme": "dark", "timeout": 30})
data2 = pickle.dumps(c2)
print(pickle.loads(data2))  # Config({'theme': 'dark', 'timeout': 30})

This approach lets you evolve your class while maintaining backward compatibility with previously pickled data.

The Config example shows how reducers can smooth over API changes without breaking existing pickled objects. By always emitting the new argument format, you ensure that future unpickling always uses the current constructor signature. If you plan to ship pickled data between services or save it to disk for long-term storage, planning your reducer around forward compatibility from the start will save you painful migration headaches later on.

A practical strategy is to version your reducer output. Include a version number in the reconstruction tuple alongside the constructor arguments. When unpickling, the constructor reads the version and applies any needed migrations. This decouples the data format from the class API and lets you evolve both independently over time. If you skip versioning and later need to change the constructor signature, you will have to decide between breaking backward compatibility or maintaining a constructor that grows increasingly complex as it supports more legacy formats.

Practical example: pickling file objects

One common pain point is pickling objects that contain file handles. Here’s how to handle it:

import copyreg
import pickle
from io import StringIO

class LogBuffer:
    """A buffer that can be pickled by excluding the file handle."""
    
    def __init__(self, filename):
        self.filename = filename
        self.buffer = StringIO()
        self._file = None
    
    def write(self, message):
        self.buffer.write(message)
    
    def flush(self):
        # Write to actual file if available
        if self._file:
            self._file.write(self.buffer.getvalue())
            self._file.flush()
            self.buffer.truncate(0)
    
    def __getstate__(self):
        state = self.__dict__.copy()
        # Don't pickle the file handle
        state["_file"] = None
        return state
    
    def __setstate__(self, state):
        self.__dict__.update(state)
        # Reopen file if it existed
        if self.filename:
            self._file = open(self.filename, "a")
    
    def __repr__(self):
        return f"LogBuffer({self.filename}, buffered={len(self.buffer.getvalue())})"

def reduce_log_buffer(buffer):
    return (LogBuffer, (buffer.filename,))

copyreg.pickle(LogBuffer, reduce_log_buffer)

# Example usage
log = LogBuffer("/tmp/app.log")
log.write("Line 1\n")
log.write("Line 2\n")

# Pickle it (file handle is excluded)
data = pickle.dumps(log)
print(f"Pickled size: {len(data)} bytes")

# Unpickle (file handle is re-opened)
restored = pickle.loads(data)
print(restored)

Summary

The copyreg module decouples serialization logic from class definitions, which keeps your objects clean and your pickling strategy flexible. Register a reducer for each custom type that pickle needs to handle, and let the reducer decide the constructor and arguments. Use __getstate__ and __setstate__ to manage attributes that cannot travel through the constructor directly, such as file handles or cached data. If your data format evolves over time, write version-aware constructors that accept both old and new argument shapes so that previously pickled objects remain readable. The separation that copyreg provides means you can update your serialization strategy without touching class internals, and subclasses automatically inherit the reducer unless overridden.

See Also