Serialization is turning an object into bytes you can store or send, and back again. Python gives you several ways to do it, and the differences between them are mostly about trust.
JSON, and the types it refuses
import json
order = {"item": "Pizza", "qty": 2, "price": 15.5, "tags": ["hot"], "note": None}
encoded = json.dumps(order)
print(encoded)
# Output: {"item": "Pizza", "qty": 2, "price": 15.5, "tags": ["hot"], "note": null}
back = json.loads(encoded)
print(back["note"] is None) # Output: True
print(type(back["tags"])) # Output: <class 'list'>
JSON covers dict, list, str, int, float, bool and None. Note None became
null and came back as None — the mapping is lossless for those types.
It has no date type, no decimal, no set and no tuple. A datetime raises on the way
out:
import json
from datetime import datetime
json.dumps({"when": datetime(2026, 8, 20)})
TypeError: Object of type datetime is not JSON serializable. Tuples do not raise — they
come back as lists, silently, which is the more dangerous behaviour of the two.
Custom encoders
import json
from datetime import datetime, date
from decimal import Decimal
def encode(value):
"""Called only for values json cannot handle itself."""
if isinstance(value, (datetime, date)):
return value.isoformat()
if isinstance(value, Decimal):
return str(value) # str, not float — a float would lose precision
if isinstance(value, set):
return sorted(value)
raise TypeError(f"cannot serialize {type(value).__name__}")
payload = {
"when": datetime(2026, 8, 20, 15, 0),
"total": Decimal("1250.00"),
"tags": {"b", "a"},
}
print(json.dumps(payload, default=encode, sort_keys=True))
# Output: {"tags": ["a", "b"], "total": "1250.00", "when": "2026-08-20T15:00:00"}
default= is called only for objects json cannot handle, so it costs nothing on ordinary
payloads. Raising TypeError for anything you did not plan for is deliberate — returning
str(value) as a catch-all turns every unexpected object into a useless string that looks
fine until someone tries to read it back.
Decimal to a string, never a float. Converting to float here undoes the
entire reason you used Decimal, and it does it silently.
Getting the types back
import json
from datetime import datetime
from decimal import Decimal
raw = '{"when": "2026-08-20T15:00:00", "total": "1250.00"}'
def decode(pairs):
out = {}
for key, value in pairs:
if key == "when":
out[key] = datetime.fromisoformat(value)
elif key == "total":
out[key] = Decimal(value)
else:
out[key] = value
return out
loaded = json.loads(raw, object_pairs_hook=decode)
print(type(loaded["when"]).__name__) # Output: datetime
print(loaded["total"] + Decimal("1")) # Output: 1251.00
JSON is asymmetric: encoding is automatic, decoding is not. The document does not
record that "1250.00" was a Decimal, so something on the way back in has to
know. That knowledge is your schema, whether you write it down or not — which is the argument for the
libraries at the end of this post.
pickle is not a file format
import pickle
from decimal import Decimal
from datetime import datetime
payload = {"when": datetime(2026, 8, 20), "total": Decimal("1250.00"), "tags": {"a", "b"}}
blob = pickle.dumps(payload)
back = pickle.loads(blob)
print(back == payload) # Output: True
print(type(back["total"]).__name__) # Output: Decimal
print(type(back["tags"]).__name__) # Output: set
Everything survives — Decimal, datetime, set, and your own classes — with no encoder to write. That is the appeal, and it comes with a condition that is not a footnote:
Unpickling runs code. The format includes instructions to import modules and call
callables, so pickle.loads() on untrusted bytes is arbitrary code execution, before any
of your validation runs. No amount of checking the result afterwards helps.
So: pickle is fine for a cache your own process wrote to your own disk. It is never acceptable for anything arriving over a network, uploaded by a user, or stored where someone else can write. It is also version-fragile — rename a class and old pickles stop loading.
YAML, and the same rule
import yaml
text = """
currency: USD
overdraft: 500
features:
- transfer
- statements
"""
config = yaml.safe_load(text)
print(config["overdraft"] + 1) # Output: 501
print(config["features"][0]) # Output: transfer
print(yaml.safe_dump({"a": 1}, default_flow_style=False).strip()) # Output: a: 1
safe_load, never load. Plain yaml.load can
construct arbitrary Python objects and carries the same code execution problem pickle does. Modern
PyYAML warns when you call it without a loader; do not silence the warning, change the call.
YAML's other trap is that it is too clever about types — unquoted no, on
and off have historically been read as booleans, and version numbers like
1.10 become floats. Quote anything you mean as a string.
Dataclasses round-trip without the mapping
import json
from dataclasses import dataclass, asdict, field
@dataclass
class Account:
number: str
balance: float
tags: list[str] = field(default_factory=list)
@dataclass
class Customer:
name: str
accounts: list[Account]
c = Customer("Alice", [Account("1001-0001", 1250.0, ["primary"])])
encoded = json.dumps(asdict(c))
print(encoded)
# Output: {"name": "Alice", "accounts": [{"number": "1001-0001", "balance": 1250.0, "tags": ["primary"]}]}
raw = json.loads(encoded)
back = Customer(raw["name"], [Account(**a) for a in raw["accounts"]])
print(back == c) # Output: True
asdict() walks nested dataclasses recursively, so the whole tree becomes plain dicts in
one call. Coming back needs the Account(**a) step — asdict has no inverse,
because the JSON does not say which class each dict was.
That last line is the whole problem in miniature, and it is where a schema library starts paying for itself.
When to use a library
Hand-written encoders are fine for a handful of types you control. Past that, the decode side is where the bugs are, and two libraries exist to own it.
Pydantic validates and converts from type hints you were writing anyway, and is the default choice for API payloads and configuration. marshmallow declares schemas separately from the model, which suits keeping the wire format decoupled from your classes.
The decision is simple: if the data comes from outside your program, you need validation, and
validation is what these do. If it is your own cache on your own disk, json or
pickle is enough.
Next: Packaging & Publishing.