Skip to content

Serialization and Data Formats

The json module is the standard way to serialize Python objects to JSON and back. It ships with CPython and uses a C extension for performance.

import json
data = {
"host": "db-primary",
"port": 5432,
"replicas": ["db-replica-1", "db-replica-2"],
"config": {"pool_size": 10, "timeout": 30.5},
"read_only": False,
"version": None,
}
## Serialize to string
json_str = json.dumps(data, indent=2, sort_keys=True)
print(json_str)
## Deserialize from string
parsed = json.loads(json_str)
print(parsed["host"]) # db-primary
import json
# Write to file
with open("config.json", "w") as f:
json.dump(data, f, indent=2)
# Read from file
with open("config.json") as f:
config = json.load(f)

json.dumps accepts a default parameter for objects that are not JSON-serializable by default:

import json
from datetime import datetime, timezone
from decimal import Decimal
class CustomEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, datetime):
return obj.isoformat()
if isinstance(obj, Decimal):
return float(obj)
if isinstance(obj, set):
return list(obj)
if isinstance(obj, bytes):
return obj.decode("utf-8", errors="replace")
return super().default(obj)
data = {
"created": datetime(2025, 1, 15, 10, 30, tzinfo=timezone.utc),
"price": Decimal("19.99"),
"tags": {"python", "json"},
}
json_str = json.dumps(data, cls=CustomEncoder, indent=2)
print(json_str)
import json
from datetime import datetime
def custom_decoder(dct):
if "created" in dct and isinstance(dct["created"], str):
dct["created"] = datetime.fromisoformat(dct["created"])
return dct
json_str = "{"created": "2025-01-15T10:30:00+00:00", "name": "test"}'
data = json.loads(json_str, object_hook=custom_decoder)
print(type(data["created"])) # <class 'datetime.datetime'>
import json
import timeit
data = {"key": "value", "numbers": list(range(1000))}
# dumps/load is faster than dump/load for in-memory operations
# C extension is ~10x faster than pure Python fallback
t = timeit.timeit(lambda: json.dumps(data), number=100000)
print(f"dumps: {t:.3f}s for 100000 iterations")

pickle serializes Python objects into a binary format. Unlike JSON, it can serialize almost any Python object, including custom classes, functions, and circular references.

import pickle
class Server:
def __init__(self, host, port, credentials):
self.host = host
self.port = port
self.credentials = credentials
def __repr__(self):
return f"Server({self.host!r}, {self.port})"
srv = Server("db.example.com", 5432, {"user": "admin", "pass": "secret"})
# Serialize
data = pickle.dumps(srv, protocol=pickle.HIGHEST_PROTOCOL)
# Deserialize
srv2 = pickle.loads(data)
print(srv2) # Server('db.example.com', 5432)
print(srv2.credentials) # {'user': "admin'', "pass': "secret''}
ProtocolPython VersionFeatures
0AllASCII, human-readable, backward compatible
11.4Binary format
22.3New-style classes
33.0Bytes objects, no implicit string conversion
43.4Large objects, more types
53.8Out-of-band data, ZSTD compression support
import pickle
print(pickle.DEFAULT_PROTOCOL) # 5 (on Python 3.12)
print(pickle.HIGHEST_PROTOCOL) # 5
# Use highest protocol for best performance and compatibility
data = pickle.dumps(obj, protocol=pickle.HIGHEST_PROTOCOL)

The standard pickle cannot serialize lambda functions, dynamically defined classes, or objects Defined in __main__. cloudpickle extends pickle to handle these cases:

import cloudpickle
# Standard pickle cannot handle this:
func = lambda x: x ** 2
# pickle.dumps(func) # AttributeError: Can"t pickle local object
# cloudpickle handles it:
data = cloudpickle.dumps(func)
func2 = cloudpickle.loads(data)
print(func2(5)) # 25

cloudpickle is used by distributed computing frameworks like PySpark, Dask, and Ray to ship Closures across processes.

The __reduce__ method controls how an object is pickled:

import pickle
class Config:
def __init__(self, path):
self.path = path
self._data = self._load(path)
def _load(self, path):
return {"loaded_from": path}
def __reduce__(self):
# Return (callable, args) — pickle will call Config(*args)
return (Config, (self.path,))
c = Config("/etc/app/config.yaml")
data = pickle.dumps(c)
c2 = pickle.loads(data)
print(c2._data) # {'loaded_from': "/etc/app/config.yaml''}

YAML is a human-readable data serialization format. Python uses the third-party PyYAML library.

import yaml
config = {
"database": {
"host": "localhost",
"port": 5432,
"name": "production",
},
"logging": {
"level": "INFO",
"handlers": ["stdout", "file"],
},
}
# Serialize
yaml_str = yaml.dump(config, default_flow_style=False, sort_keys=False)
print(yaml_str)
# Deserialize
data = yaml.safe_load(yaml_str)
print(data["database"]["host"]) # localhost
import yaml
# Define a custom constructor that only creates known types
def env_variable_constructor(loader, node):
value = loader.construct_scalar(node)
import os
return os.getenv(value, value)
# Register with a custom SafeLoader
class CustomSafeLoader(yaml.SafeLoader):
pass
CustomSafeLoader.add_constructor("!env", env_variable_constructor)
yaml_str = """
host: !env HOST_NAME
port: !env PORT_NUMBER
"""
data = yaml.load(yaml_str, Loader=CustomSafeLoader)
print(data) # {'host': "localhost'', "port': "localhost''} (or env values)

TOML (Tom”s Obvious Minimal Language) is designed for configuration files. Python 3.11+ includes tomllib in the standard library.

import tomllib # Python 3.11+
with open("pyproject.toml", "rb") as f:
config = tomllib.load(f)
print(config["project"]["name"])
print(config["tool"]["pytest"]["testpaths"])
# pip install tomli
import tomli
with open("pyproject.toml", "rb") as f:
config = tomli.load(f)

Python’s built-in tomllib is read-only. For writing, use tomli_w:

# pip install tomli_w
import tomli_w
config = {
"database": {
"host": "localhost",
"port": 5432,
},
"logging": {
"level": "INFO",
},
}
with open("config.toml", "wb") as f:
tomli_w.dump(config, f)
[server]
host = "0.0.0.0"
port = 8080
debug = false
[server.cors]
allowed_origins = ["https://example.com", "https://app.example.com"]
max_age = 3600
[database]
host = "localhost"
port = 5432
name = "myapp"
[[users]]
name = "admin"
role = "superuser"
[[users]]
name = "viewer"
role = "readonly"

The csv module handles reading and writing CSV files.

import csv
# Basic reading
with open("data.csv", newline="") as f:
reader = csv.reader(f)
headers = next(reader)
for row in reader:
print(dict(zip(headers, row)))
# DictReader — recommended for most use cases
with open("data.csv", newline="") as f:
reader = csv.DictReader(f)
for row in reader:
print(row["name"], row["email"])
import csv
headers = ["name", "email", "role"]
rows = [
{"name": "Alice", "email": "alice@example.com", "role": "admin"},
{"name": "Bob", "email": "bob@example.com", "role": "user"},
]
with open("output.csv", "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=headers)
writer.writeheader()
writer.writerows(rows)
import csv
# Control quoting behavior
with open("quoted.csv", "w", newline="") as f:
writer = csv.writer(f, quoting=csv.QUOTE_ALL)
writer.writerow(["name", "description"])
writer.writerow(['Alice', 'Has a "title" and, commas'])
# csv.QUOTE_MINIMAL (default) — quote only when necessary
# csv.QUOTE_ALL — quote everything
# csv.QUOTE_NONNUMERIC — quote non-numeric values
# csv.QUOTE_NONE — never quote (raises error if quoting needed)

Serialization is converting objects to bytes or strings for storage or transmission. json is the universal format for web APIs, pickle serialises arbitrary Python objects (but never unpickle untrusted data), and csv handles tabular data. dataclasses.asdict() converts dataclasses to dictionaries, making JSON serialisation trivial. The key trade-off is between human readability (JSON, CSV) and Python-specific capability (pickle, marshal).