JSON In and Out
Encode and decode JSON with the stdlib, and know which Python types do not survive the round trip unchanged.
json.dumps(obj, *, default=None, sort_keys=False, indent=None) -> strJSON is the wire format for almost every API and config file you will touch, and the json module is the bridge between it and Python objects. json.loads(s) takes text and returns a dict, list, str, int, float, bool or None; json.dumps(obj) goes the other way. The file forms are the same functions with one step folded in: json.load(f) is json.loads(f.read()), and json.dump(obj, f) is f.write(json.dumps(obj)). Same encoder, same arguments.
What survives a round trip
Less than you expect. JSON has objects, arrays, strings, numbers, true, false and null and nothing else. A tuple serialises as an array and comes back a list. Dict keys are coerced to strings, so the docs warn that loads(dumps(x)) != x when x has non-string keys. A set, a Decimal and a datetime have no equivalent at all and raise TypeError.
default= and sort_keys
default= is a one-argument function called only for objects the encoder cannot handle; it returns something encodable or raises TypeError. It is the shorthand for subclassing json.JSONEncoder and overriding its default method — the same hook with the same contract, so use whichever you prefer. sort_keys=True orders object keys, which makes two dumps of equal dicts byte-identical and therefore diffable in a commit.
Bad input raises json.JSONDecodeError, a subclass of ValueError carrying msg, doc, pos, lineno and colno — enough to point at the offending character.
Example
When to use it
- Read a config file at startup and fail loudly on a syntax error with the line and column.
- Serialise a record containing Decimal amounts and datetimes for a POST body.
- Produce JSON fixtures that diff line by line in code review instead of reordering on every write.
- Decode an API response into dataclasses rather than letting bare dicts spread through the codebase.
More examples
Decoding money without touching float
parse_float (and its siblings parse_int and parse_constant) replace the constructor the decoder uses for that JSON type. Reach for this whenever the payload carries currency amounts: by the time a float exists it is already too late, and no amount of Decimal() later recovers the lost digits.
import json
from decimal import Decimal
# A payments webhook body. parse_float=Decimal is called for every JSON number
# written with a decimal point, so amounts never become binary floats.
body = '{"id": "ch_1", "amount": 12.10, "currency": "eur"}'
charge = json.loads(body, parse_float=Decimal)
print(charge["amount"] * 3) # Decimal('36.30'), exactly
# In a real service the text comes from an HTTP client:
# resp = httpx.get(url)
# charge = json.loads(resp.text, parse_float=Decimal)
# resp.json() would hand you floats, which is why the text is parsed by hand.A JSONEncoder subclass for a committed config file
Use the class form when the same conversion rules apply across a whole project; cls=StrictEncoder then replaces a default= argument repeated at every call site. Note the call to super().default(o) at the end: without it, an unsupported type returns None instead of failing, and you ship a config full of nulls.
import json
from datetime import date, datetime
from decimal import Decimal
from pathlib import Path
class StrictEncoder(json.JSONEncoder):
"""Subclassing and overriding default() is the longhand for default=."""
def default(self, o):
if isinstance(o, (datetime, date)):
return o.isoformat()
if isinstance(o, Decimal):
return str(o)
if isinstance(o, set):
return sorted(o) # sorted, so the output is stable
return super().default(o) # raises TypeError for anything else
config = {"version": 3, "features": {"beta", "eu"}, "updated": date(2026, 3, 1)}
# sort_keys + indent + a trailing newline: a file that diffs one line at a time.
Path("config.json").write_text(
json.dumps(config, cls=StrictEncoder, sort_keys=True, indent=2) + "\n",
encoding="utf-8",
)object_hook: decode straight into your own types
object_hook is the decoding mirror of default=: one function that upgrades raw dicts into real objects as they are parsed. It is the cheapest way to stop a decoded payload spreading dicts through your code, and it pairs with object_pairs_hook when you need the key order or duplicate keys.
import json
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
x: int
y: int
def revive(obj):
# object_hook is called with every decoded JSON object, innermost first.
if obj.keys() == {"x", "y"}:
return Point(obj["x"], obj["y"])
return obj # anything unrecognised stays a plain dict
shape = json.loads(
'{"name": "wall", "ends": [{"x": 0, "y": 0}, {"x": 3, "y": 4}]}',
object_hook=revive,
)
print(shape["ends"][1].y) # 4 - a Point, not a dict
Discussion