Beyond json.dumps: A Practical Deep Dive into Python's json Module
Learn how Python's json module really works: pretty-printing and compact output, serializing datetimes, Decimals, and dataclasses with custom encoders, precise decoding with parse_float and object_hook, and the type-mapping gotchas that bite in production.
JSON is the lingua franca of APIs, config files, and data pipelines — and Python ships a capable parser and serializer in the standard library. Most developers stop at json.dumps() and json.loads(), then hit a wall the first time they try to serialize a datetime, lose precision on a money field, or wonder why their tuple came back as a list. This post goes past the basics: the exact type mapping, the formatting knobs, custom encoding and decoding, and the pitfalls that show up in real systems.
The four core functions
The module has two pairs of functions: the s-suffixed ones work with strings, the others with file-like objects.
import json
data = {"name": "Ada", "skills": ["math", "code"], "active": True}
text = json.dumps(data) # dict -> str
back = json.loads(text) # str -> dict
with open("user.json", "w", encoding="utf-8") as f:
json.dump(data, f) # dict -> file
with open("user.json", encoding="utf-8") as f:
back = json.load(f) # file -> dict
Prefer json.dump()/json.load() when a file is involved — they avoid building the whole string in memory as an intermediate step and make the intent obvious.
Know the type mapping (and where it's lossy)
Serialization follows a fixed table: dict becomes an object, list and tuple become arrays, str becomes a string, int and float become numbers, True/False/None become true/false/null. Decoding reverses it — but not symmetrically. Two conversions are lossy and regularly surprise people:
>>> json.loads(json.dumps((1, 2, 3)))
[1, 2, 3] # tuples come back as lists
>>> json.dumps({1: "one", "b": 2})
'{"1": "one", "b": 2}' # non-string keys are coerced to strings
Key coercion has a nasty edge case: a dict can legally hold both the int 1 and the string "1", and serializing it produces JSON with duplicate keys. When such JSON is parsed, json.loads() silently keeps the last value:
>>> json.dumps({1: "int key", "1": "str key"})
'{"1": "int key", "1": "str key"}'
>>> json.loads('{"1": "int key", "1": "str key"}')
{'1': 'str key'}
If your dict keys aren't strings, normalize them yourself before serializing — don't let the encoder decide.
Formatting: pretty for humans, compact for wires
json.dumps() takes several formatting parameters worth knowing:
payload = {"city": "Zürich", "temp": 21.5, "tags": ["a", "b"]}
# Human-readable: indentation + stable key order (great for diffs and config files)
print(json.dumps(payload, indent=2, sort_keys=True))
# Wire format: strip the default spaces after ',' and ':'
compact = json.dumps(payload, separators=(",", ":"))
# Keep non-ASCII characters readable instead of \uXXXX escapes
readable = json.dumps(payload, ensure_ascii=False)
ensure_ascii=False is almost always what you want for logs and files that humans read — accented characters like the ü in "Zürich" stay readable instead of being replaced by backslash-u escape sequences. Just make sure the destination is opened with UTF-8 encoding. sort_keys=True gives deterministic output, which matters when you hash or diff JSON documents.
Serializing the types JSON forgot: default=
Out of the box, json.dumps() raises TypeError for datetime, Decimal, set, UUID, dataclasses, and everything else outside the core table. The cleanest fix is the default parameter — a function called for any object the encoder can't handle:
import json
from dataclasses import asdict, is_dataclass
from datetime import datetime, timezone
from decimal import Decimal
def to_jsonable(obj):
if isinstance(obj, datetime):
return obj.isoformat()
if isinstance(obj, Decimal):
return str(obj) # string, not float: preserves precision
if isinstance(obj, set):
return sorted(obj)
if is_dataclass(obj) and not isinstance(obj, type):
return asdict(obj)
raise TypeError(f"Cannot serialize {type(obj).__name__}")
record = {
"created": datetime(2026, 7, 7, 9, 30, tzinfo=timezone.utc),
"price": Decimal("19.99"),
"labels": {"python", "json"},
}
print(json.dumps(record, default=to_jsonable))
# {"created": "2026-07-07T09:30:00+00:00", "price": "19.99", "labels": ["json", "python"]}
Two details matter here. First, always end your default function by raising TypeError — returning None for unknown types would silently corrupt data. Second, serialize Decimal as a string: converting to float reintroduces exactly the rounding errors you used Decimal to avoid.
For reusable behavior you can subclass json.JSONEncoder and override its default() method, then pass cls=MyEncoder — functionally equivalent, handy when a framework asks for an encoder class rather than a function.
Precise decoding: parse_float and object_hook
Decoding has hooks too. If you're parsing financial data, tell the decoder to build Decimal values directly instead of lossy floats:
from decimal import Decimal
doc = json.loads('{"total": 19.99}', parse_float=Decimal)
print(doc["total"]) # Decimal('19.99') — exact, not 19.989999...
object_hook runs on every decoded JSON object (innermost first) and lets you transform plain dicts into richer values on the way in:
from datetime import datetime
def revive(d):
for key, value in d.items():
if key.endswith("_at") and isinstance(value, str):
try:
d[key] = datetime.fromisoformat(value)
except ValueError:
pass
return d
doc = json.loads('{"created_at": "2026-07-07T09:30:00+00:00"}', object_hook=revive)
print(type(doc["created_at"])) # <class 'datetime.datetime'>
Because JSON has no date type, a naming convention (like an _at suffix) plus a hook is a pragmatic middle ground when you don't want a full schema library.
Common pitfalls
NaN and Infinity are not valid JSON
Python happily emits NaN, Infinity, and -Infinity by default — tokens most other parsers (including JavaScript's JSON.parse) reject. If your output leaves the Python world, be strict:
json.dumps(float("nan"), allow_nan=False) # raises ValueError instead
Catch the error and decide explicitly — usually replacing the value with None upstream.
loads() rejects trailing data
json.loads() parses exactly one document. For newline-delimited JSON (JSONL, a common log and export format), parse line by line:
with open("events.jsonl", encoding="utf-8") as f:
events = [json.loads(line) for line in f if line.strip()]
The module doesn't stream
json.load() reads the entire document into memory before returning. For multi-gigabyte files, the standard library has no incremental parser — reach for a third-party package like ijson, or better, produce JSONL so each record parses independently.
Don't parse untrusted input into surprises
json.loads() is safe in the sense that it never executes code (unlike eval() or unpickling). But deeply nested documents can still exhaust the recursion limit, and huge documents exhaust memory — validate size limits before parsing input from the network.
When to reach beyond the standard library
The stdlib module is pure Python plus a C accelerator and is fast enough for most workloads. If serialization dominates your profile — high-throughput APIs, big export jobs — third-party encoders like orjson offer substantial speedups and native datetime/dataclass handling, at the cost of a compiled dependency and slightly different APIs (orjson.dumps() returns bytes, not str). Measure first; switch only if JSON is actually your bottleneck.
Wrap-up and next steps
The json module rewards knowing its edges: use dump/load with files, ensure_ascii=False for human-readable output, separators=(",", ":") for compact wire format, a default function for datetimes and Decimals, and parse_float=Decimal when precision matters. Watch the lossy conversions — tuples, dict keys, NaN — and normalize data before it hits the encoder. From here, try round-tripping one of your own data models through a default/object_hook pair, and if you need validation on top of parsing, that's the road that leads to schema libraries like pydantic.