Skip to content

Custom Serialization

In this guide you'll implement a custom serializer using cloudpickle that can handle lambdas and closures. Along the way you'll learn the Serializer protocol, build a working implementation, and prove it roundtrips functions through a real actor.

The Serializer Protocol

Casty uses a Protocol, not an ABC. Any object with these two methods satisfies it:

#   class Serializer(Protocol):
#       def serialize(self, obj: Any) -> bytes: ...
#       def deserialize(self, data: bytes, *, ref_factory=None) -> Any: ...

serialize converts to bytes. deserialize converts back. The optional ref_factory keyword argument lets the transport layer pass a factory for reconstructing ActorRef objects during deserialization.

CloudpickleSerializer

The implementation is minimal — cloudpickle does the heavy lifting:

class CloudpickleSerializer:
    """Serializer that uses cloudpickle for lambdas and closures."""

    def serialize[M](self, obj: M) -> bytes:
        return cloudpickle.dumps(obj)

    def deserialize[M, R](
        self,
        data: bytes,
        *,
        ref_factory: Callable[[ActorAddress], ActorRef[R]] | None = None,
    ) -> M:
        return cloudpickle.loads(data)  # noqa: S301

No inheritance, no registration. If it quacks like a Serializer, it is a Serializer.

Why Cloudpickle?

Standard pickle can't serialize lambdas defined inside functions. Cloudpickle can:

def pickle_vs_cloudpickle() -> None:
    print("── Standard pickle vs cloudpickle ──")
    try:
        pickle.dumps(lambda x: x + 1)
        print("  pickle:      OK (unexpected)")
    except Exception as e:
        print(f"  pickle:      {type(e).__name__}")

    cloudpickle.dumps(lambda x: x + 1)
    print("  cloudpickle: OK")

Output:

pickle:      AttributeError
cloudpickle: OK

Roundtripping Closures

Cloudpickle captures local variables along with the lambda. multiplier = 3 survives the serialize-deserialize roundtrip:

def roundtrip_closure(serializer: CloudpickleSerializer) -> None:
    print("\n── Roundtrip a closure ──")
    multiplier = 3
    fn: Callable[[int], int] = lambda x: x * multiplier  # noqa: E731

    data = serializer.serialize(fn)
    restored_fn = serializer.deserialize(data)
    print(f"  restored_fn(10) = {restored_fn(10)}")

Messages with Lambdas

A frozen dataclass carrying a callable roundtrips cleanly:

@dataclass(frozen=True)
class ApplyFn:
    fn: Callable[[int], int]


@dataclass(frozen=True)
class GetValue:
    reply_to: ActorRef[int]


type ComputeMsg = ApplyFn | GetValue
def roundtrip_message(serializer: CloudpickleSerializer) -> None:
    print("\n── Roundtrip a message ──")
    msg = ApplyFn(fn=lambda x: x + 42)
    data = serializer.serialize(msg)
    restored_msg = serializer.deserialize(data)
    print(f"  fn(0) = {restored_msg.fn(0)}")

Actor Applying Serialized Functions

The full loop: serialize a message carrying a lambda, deserialize it, and tell() it to an actor that applies the function to its state:

def accumulator(value: int = 0) -> Behavior[ComputeMsg]:
    async def receive(
        ctx: ActorContext[ComputeMsg], msg: ComputeMsg
    ) -> Behavior[ComputeMsg]:
        match msg:
            case ApplyFn(fn):
                new_value = fn(value)
                print(f"  {value}{new_value}")
                return accumulator(new_value)
            case GetValue(reply_to):
                reply_to.tell(value)
                return Behaviors.same()

    return Behaviors.receive(receive)
async def actor_with_serialized_fns(serializer: CloudpickleSerializer) -> None:
    print("\n── Actor applying serialized functions ──")
    async with ActorSystem() as system:
        ref: ActorRef[ComputeMsg] = system.spawn(accumulator(), "calc")

        fns: list[Callable[[int], int]] = [
            lambda x: x + 10,
            lambda x: x * 3,
            lambda x: x - 5,
        ]
        for fn in fns:
            data = serializer.serialize(ApplyFn(fn=fn))
            ref.tell(serializer.deserialize(data))

        await asyncio.sleep(0.1)

        value: int = await system.ask(
            ref, lambda r: GetValue(reply_to=r), timeout=5.0
        )
        print(f"  Final value: {value}")

Output:

0 → 10
10 → 30
30 → 25
Final value: 25

Run the Full Example

pip install cloudpickle
git clone https://github.com/gabfssilva/casty.git
cd casty
uv run python examples/guides/08_custom_serialization.py

What you learned:

  • Serializer is a Protocol — implement two methods and you're done. No inheritance required.
  • Cloudpickle handles lambdas, closures, and captured variables that standard pickle cannot.
  • Messages carrying callables roundtrip cleanly through cloudpickle serialization.
  • Structural subtyping means your serializer satisfies the protocol without any base class.