Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Serialisation & Storage  ›  Lesson

Pickle, and Why You Must Not Load Untrusted Data

Pickle 22 minPython's own object format: everything round-trips, including code execution
You're building a piece ofQuarry — the vault
This piece — cache_batch(): Parks a parsed batch as bytes and brings it back with every Python type intact.
Scenario Parsing last night's forty thousand incidents took most of a minute. Quarry wants to park the parsed batch so tomorrow's run can skip straight to the report.
Your task
Build cache_batch(raws): build each record with build_record, pickle the list with pickle.HIGHEST_PROTOCOL, load it back, and return {"count": number restored, "intact": restored == records, "kinds": sorted type names of every value}. Example: one incident → {"count": 1, "intact": True, "kinds": ["Decimal", "datetime", "int", "set", "str"]}.

Pickle, and Why You Must Not Load Untrusted Data

In short: pickle.dumps(obj) turns almost any Python object into bytes and pickle.loads(data) rebuilds it exactly — tuples stay tuples, int keys stay ints, datetimes and sets need no hooks, and shared references survive. That's the opposite of JSON's lossy border crossing, and the price is steep: pickle.loads doesn't parse data, it runs a small program. The bytes name a callable and arguments, and loading them calls that callable. If the bytes came from anyone but you, they can name os.system as easily as datetime. Pickle is for a program talking to itself — never for data that crossed a trust boundary.

You'll build cache_batch(raws), the vault's batch cache: park a parsed batch as bytes, and get back exactly the records you had.

pickle.loads doesn't read data, it makes calls: GLOBAL imports a name, REDUCE calls it with arguments from the stream — fine for Quarry's own file, dangerous for one from a shared folder

Cache a parsed batch so tomorrow's run skips the parsing

Parsing is the slow part of Quarry's pipeline. If the source file hasn't changed, loading yesterday's parsed batch beats reparsing it:

python

back == record is True. The tuple is still a tuple, the int keys are still ints — every lossy edge from the JSON lesson is gone. Pickle even tracks which names point at the same object:

What does this print?
import pickle

shared = ["web"]
pair = [shared, shared]          # two references to ONE list
back = pickle.loads(pickle.dumps(pair))
back[0].append("disk")
print(back[1])

What pickle can't carry is code: a function is pickled by reference — module and name — and reimported at load time. A lambda has no name to look up. A generator is a paused stack frame with nothing to name. Fix this batch so it pickles, keeping the rows intact:

broken — fix it

Run it and read which object pickle refused. Then change the batch so it holds only data.

Open a batch cache written by someone you don't trust

Here's where the convenience turns dangerous. pickletools.dis shows what's really in the bytes:

python

That's not a data layout — it's instructions for a tiny stack machine. GLOBAL 'datetime date' imports datetime and pushes the date class. REDUCE pops a callable and a tuple of arguments, calls the callable, and pushes the result. Rebuilding a date means calling date(...) — and that's exactly how pickle restores anything: by calling whatever the stream names, with whatever arguments the stream supplies. A class controls its own rebuild with __reduce__:

What does this print?
import pickle

class Sneaky:
    def __reduce__(self):
        return (str.upper, ("owned",))

blob = pickle.dumps(Sneaky())
print(pickle.loads(blob))

str.upper is harmless; an attacker picks something that isn't. And the call happens inside loads, before your code ever sees the result — no check afterwards can help.

Drawing diagram…

Here's that machine cut down to five instructions you can trace by hand:

step through it
1CALLABLES = {"upper": str.upper, "len": len}
2 
3program = [("GLOBAL", "upper"), ("PUSH", "owned"), ("TUPLE", 1), ("REDUCE", None), ("STOP", None)]
4stack = []
5for op, arg in program:
6 if op == "GLOBAL":
7 stack.append(CALLABLES[arg])
8 elif op == "PUSH":
9 stack.append(arg)
10 elif op == "TUPLE":
11 items = stack[-arg:]
12 del stack[-arg:]
13 stack.append(tuple(items))
14 elif op == "REDUCE":
15 args = stack.pop()
16 func = stack.pop()
17 stack.append(func(*args))
18 elif op == "STOP":
19 break
20print(stack.pop())

Sign a cache so a tampered file is refused

Never unpickle data you did not produce yourself, from a place nobody else can write to.

A cache in a shared directory, an upload, a queue message: each is "untrusted" the moment someone other than you could have written it. The real fix is to not use pickle across a trust boundary at all — reach for JSON or a database instead when data crosses between programs or people. When Quarry writes its own cache and reads it back, signing proves the bytes are yours: attach an HMAC, a keyed hash made with a secret only Quarry knows, and check it before calling loads:

python

The comparison uses hmac.compare_digest, not ==, so the time it takes doesn't leak how many bytes matched. A short checksum like CRC-16 is broken almost at once; HMAC-SHA256's 256 bits put a forged tag out of reach. Signing proves the bytes are yours — it does not make hostile bytes safe, which is why the real rule is still "don't cross a trust boundary with pickle".

Quick practice

What does this print?
import pickle

print(pickle.loads(pickle.dumps((1, 2, 3))) == (1, 2, 3))
broken — fix itTypeError

Run it, read which object pickle refuses, then pickle a realised list instead of the generator and print "ok".

What does this print?
import pickle

class Payload:
    def __reduce__(self):
        return (len, ([1, 2, 3],))

print(pickle.loads(pickle.dumps(Payload())))

🎯 Your turn

Write cache_batch(raws). The starter's build_record(raw) is the same stand-in record stage as in the JSON lesson. Your function should:

  1. build records = [build_record(r) for r in raws]
  2. pickle the list to bytes with pickle.HIGHEST_PROTOCOL, then load it back
  3. return {"count": len(restored), "intact": restored == records, "kinds": sorted type names of every value in every restored record}

Example: one incident record → {"count": 1, "intact": True, "kinds": ["Decimal", "datetime", "int", "set", "str"]}. Every one of those types came back as itself, which JSON couldn't do.

Hint: a set comprehension, {type(v).__name__ for r in restored for v in r.values()}, collects the names without duplicates. Then sorted it.

Press ▶ Run and hit ✓ Check. ✅

Practice — level up

0/6 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upPickle versus JSON, one type at a time

Write survivor(kind). kind is one of "tuple", "set", "frozenset" or "bytes". The starter builds the value. Return {"pickle": type name after a pickle round trip, "json": type name after a JSON round trip}, and if JSON raises TypeError, use the string "TypeError" instead of a type name.

serializationpickle
DrillShared references survive

Write shared_after(items). Make inner = list(items) and pair = [inner, inner]. Round-trip pair through pickle and through JSON separately. In each result, append "new" to element 0, then report the length of element 1. Return {"pickle": ..., "json": ...}.

serializationpickle
BuildWhat pickle can carry

Write can_pickle(kind). The starter's make(kind) builds a lambda, a generator, a nested function, a dict or a datetime. Try pickle.dumps on it and return True if it works and False if it raises. (Catch Exception: which exception you get varies between Python versions, and the point is whether, not which.)

serializationpickleexceptions
BossAn unpickler with an allow-list

Subclass pickle.Unpickler as SafeUnpickler and override find_class(self, module, name): if (module, name) is in the starter's ALLOWED, return super().find_class(module, name), otherwise raise pickle.UnpicklingError(f"refused {module}.{name}"). Then write restricted_load(kind): pickle PAYLOADS[kind](), load it with SafeUnpickler(io.BytesIO(blob)).load(), and return the loaded object's type name, or the error message if it was refused.

serializationunpickler
CapstoneA signed cache that refuses tampering

Write signed_cache(raws, tamper). Seal the list: blob = pickle.dumps(raws) and sealed = hmac.new(KEY, blob, hashlib.sha256).digest() + blob. If tamper is true, flip the lowest bit of the last byte of sealed. Then open it: split off the 32-byte tag, recompute the HMAC of the rest, and compare with hmac.compare_digest before unpickling. Return {"loaded": number of records, "error": None}, or {"loaded": 0, "error": "bad signature"} if the check fails.

serializationpicklesigned-payload
ExplainWhy `pickle.loads` on untrusted bytes is code execution

A teammate wants Quarry to accept pickled batches uploaded by partner teams, "because it's faster than JSON and keeps our types". Explain, in terms of how unpickling actually works, why this lets an uploader run code on Quarry's machine, and what Quarry should do instead. Say what HMAC signing would and would not fix here.

cache_batch.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
cache_batch(raws) → dictPickle a batch of typed records, restore it, and report whether it came back unchanged.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.