๐Ÿฅ’ Pattern 8: Pickling & the Serialization Tax

Isolated memory means every argument you send a worker and every result it returns is turned into bytes and back. That has a correctness cost and a speed cost.

The Core Fact: Everything Crosses as Bytes

Because each process has its own private memory, a worker can't just read a variable the parent holds. So multiprocessing serializes every argument with pickle, ships the bytes across the OS boundary, and rebuilds (unpickles) an independent copy on the other side. The result travels back the same way. This happens on every task submission and every return.

flowchart LR O["your object
(parent)"] --> P["pickle.dumps
โ†’ bytes"] P --> IPC["IPC pipe / queue
(OS boundary)"] IPC --> U["pickle.loads
โ†’ new object"] U --> W["worker runs
on the COPY"] W --> RP["pickle result
โ†’ bytes"] RP --> RB["back across IPC"] RB --> RU["unpickle in parent"]
Key Insight: the worker never touches your object โ€” it touches a copy rebuilt from bytes. That's why mutating an argument inside a worker never affects the parent's version, and why "just pass the object" quietly means "pay to serialize the object."

What Can't Be Pickled

Pickle stores a reference to a top-level, importable name plus the object's state. Anything that can't be named that way, or that wraps a live OS resource, fails to travel.

Not picklable: lambdas and locally-defined/nested functions (AttributeError: Can't pickle local object or _pickle.PicklingError), open file handles, sockets, database connections, threading Locks, generators, and any object that captures one of those in a closure.
from concurrent.futures import ProcessPoolExecutor

if __name__ == "__main__":
    # โŒ A lambda has no importable name โ€” the parent can't pickle it to send it.
    with ProcessPoolExecutor() as pool:
        pool.map(lambda x: x * 2, range(4))
    #   _pickle.PicklingError: Can't pickle <function <lambda>>:
    #   attribute lookup <lambda> on __main__ failed

The same failure hits a function defined inside another function, or a partial that closed over a socket. The rule of thumb: if you can't import it by a stable dotted path, pickle can't either.

The Fixes

(a) Use top-level functions as targets. Define the worker function at module scope so it has an importable name.

(b) Bind arguments with functools.partial instead of a lambda โ€” a partial of a top-level function is picklable.

(c) Never pass a live connection. Pass the connection string / params and open the resource inside the worker โ€” ideally once per worker via an initializer=.
from concurrent.futures import ProcessPoolExecutor
from functools import partial

def scale(factor, x):        # top-level, importable โ€” picklable
    return x * factor

if __name__ == "__main__":
    # (a) + (b): partial binds `factor`, replacing the un-picklable lambda.
    double = partial(scale, 2)
    with ProcessPoolExecutor() as pool:
        print(list(pool.map(double, range(4))))   # [0, 2, 4, 6]

For connections, the fix is the initializer pattern: don't pickle a live DB handle (you can't). Pickle the DSN, and let each worker build its own connection once and stash it in a global for reuse.

from concurrent.futures import ProcessPoolExecutor

_CONN = None    # per-worker connection, rebuilt in each worker

def init_worker(dsn):
    global _CONN
    _CONN = connect(dsn)        # open ONCE per worker, not per task

def run_query(query):
    return _CONN.execute(query) # reuse the per-worker connection

def connect(dsn):
    return dsn                  # stand-in for a real driver

if __name__ == "__main__":
    dsn = "postgresql://localhost/app"        # a picklable STRING, not a handle
    with ProcessPoolExecutor(initializer=init_worker, initargs=(dsn,)) as pool:
        print(list(pool.map(run_query, ["SELECT 1", "SELECT 2"])))

See Pattern 6 for passing sync primitives through initargs the same way (they can't be pickled as plain arguments either).

The Serialization Tax: It's Also About Speed

Key Insight: pickling isn't only a correctness gate โ€” it's a throughput cost. Passing a 500 MB DataFrame to each of 8 workers copies 4 GB of bytes before a single row is processed. For small or fast tasks, the pickle round-trip can cost more than the work itself โ€” this is the #1 reason a ProcessPoolExecutor comes out slower than a plain sequential loop.

Rules to cut the tax:

from concurrent.futures import ProcessPoolExecutor

# โŒ Copies the whole big list into every task's pickle.
# def work(big_list, i): ...

def work(path_and_index):        # โœ… pass a tiny (path, index) tuple instead
    path, i = path_and_index
    with open(path) as f:
        line = f.readlines()[i]  # worker reads only what it needs
    return len(line)

if __name__ == "__main__":
    jobs = [("data.txt", i) for i in range(100)]   # small, cheap to pickle
    with ProcessPoolExecutor() as pool:
        print(sum(pool.map(work, jobs)))

spawn Raises the Stakes

Under the spawn start method (Pattern 7) โ€” the default on macOS and Windows โ€” pickling isn't optional plumbing you can dodge with fork's inherited memory. spawn pickles the target function's arguments and effectively requires your module to be importable so the child can rebuild the target by name. So the picklability rules here aren't a corner case: on the two most common developer platforms they govern whether your program starts at all.

Checking Picklability & the cloudpickle Escape Hatch

To test quickly whether something can travel, try to dump it in a REPL โ€” if pickle.dumps raises, so will passing it to a worker.

import pickle

pickle.dumps({"a": 1})           # ok โ†’ bytes
pickle.dumps(lambda x: x)        # raises PicklingError โ€” this would fail in a pool

There's also cloudpickle, a third-party serializer that can pickle lambdas and locally-defined functions by capturing their bytecode. It's why joblib, Dask, and Ray let you pass a lambda straight to a worker where the stdlib pool refuses. You don't get it for free with multiprocessing โ€” those libraries swap the serializer in for you. More in Common Libraries.

Decision Table: Can It Travel?

Object kindPicklable?What to do instead
Top-level functionโœ… YesUse as target directly
Lambda / nested functionโŒ Nofunctools.partial of a top-level fn, or cloudpickle
dict / list / str / int / dataclassโœ… YesFine โ€” but watch the size (the tax)
Big array / DataFrameโœ… Yes (but costly)Pass a path/index, or use shared_memory
Open file / socketโŒ NoPass the path/params; open inside the worker
DB connectionโŒ NoPass the DSN; build per-worker via initializer
Lock / Event / generatorโŒ NoPass via initargs (Pattern 6) / re-create in worker
Real-world profiling tip: if a job you know is CPU-bound runs slower with a ProcessPoolExecutor than with threads or a plain loop, suspect the serialization tax first โ€” you're almost certainly shipping fat arguments or fat results. Shrink what crosses the boundary before blaming the parallelism. The payoff, once the tax is under control, is in Pattern 10.