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:
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:
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:
Serializeris 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.