Working with JSON — the json module
JSON and Python's own data structures look almost identical, which is exactly what makes the gaps between them — tuples, dates, and dict keys — the source of nearly every real json bug.
4 min read
The four functions: dumps, loads, dump, load
import json
data = {"name": "Ada", "age": 36, "active": True}
json.dumps(data) # '{"name": "Ada", "age": 36, "active": true}' — Python object -> JSON STRING
json.loads('{"x": 1}') # {'x': 1} — JSON string -> Python object
with open("data.json", "w") as f:
json.dump(data, f) # like dumps(), but writes DIRECTLY to a file object
with open("data.json") as f:
data = json.load(f) # like loads(), but reads DIRECTLY from a file objectThe naming is consistent once it clicks: s means string. dumps/loads work with strings already in memory; dump/load (no s) work directly with a file object, writing or reading the JSON without an intermediate string. Mixing them up — say, calling json.dumps(data, f) — is a common early mistake, and fails with a confusing error rather than a helpful one.
Not every Python value has a JSON equivalent
json.dumps({"coords": (1, 2)}) # '{"coords": [1, 2]}' — a tuple becomes a JSON array (a list)
json.loads('{"coords": [1, 2]}') # {'coords': [1, 2]} — comes back as a LIST, never a tuple
json.dumps(float("nan")) # 'NaN' — technically invalid JSON, but Python allows it by default
json.dumps({1: "a", 2: "b"}) # '{"1": "a", "2": "b"}' — INTEGER keys become STRING keysJSON has no concept of a tuple, so a Python tuple serializes as a JSON array indistinguishable from a list — and deserializing always produces a list back, never a tuple, even if a tuple went in. JSON also requires string keys, so dumps silently converts integer (or any non-string) dict keys to strings — {1: "a"} round-trips as {"1": "a"}, which can quietly break code that expects to look values up by an integer key after a round trip.
Objects json doesn't know how to serialize: default=
from datetime import datetime
json.dumps({"created": datetime.now()})
# TypeError: Object of type datetime is not JSON serializable
def serialize_datetime(obj):
if isinstance(obj, datetime):
return obj.isoformat()
raise TypeError(f"Object of type {type(obj)} is not JSON serializable")
json.dumps({"created": datetime.now()}, default=serialize_datetime)
# '{"created": "2026-09-01T10:30:00"}' — works nowjson.dumps only knows how to handle the handful of built-in types with a direct JSON equivalent (dict, list, str, int, float, bool, None) — anything else, like a datetime or a custom class instance, raises TypeError by default. The default parameter takes a function that's called for exactly those unrecognized objects, letting it decide how to convert them (here, to an ISO-format string) — without it, serializing anything beyond the basic types requires converting it manually before calling dumps.
Reading back an unexpected shape: don't assume, check
data = json.loads(user_supplied_json)
# WRONG — assumes data is a dict with a "name" key that's a string; any of
# those assumptions being false raises a confusing error deep in later code
name = data["name"].upper()
# RIGHT — check the shape explicitly for anything from an external source
if isinstance(data, dict) and isinstance(data.get("name"), str):
name = data["name"].upper()
else:
raise ValueError("expected a JSON object with a string 'name' field")json.loads on external input (an API response, a file someone else wrote, user-submitted data) can return any JSON-valid shape — a list, a string, a number, None — not necessarily the dict a program expects. Indexing into it without checking raises KeyError or TypeError at the point of use, often far from where the bad data actually came in; validating the shape immediately after parsing turns a confusing downstream crash into a clear, immediate error that names exactly what was wrong.
indent: JSON for humans, not just machines
json.dumps({"name": "Ada", "roles": ["admin", "editor"]}, indent=2){
"name": "Ada",
"roles": [
"admin",
"editor"
]
}By default, dumps produces the most compact valid JSON — fine for sending over a network, unreadable for a config file or debug log a person will actually read. indent=2 (or any integer) pretty-prints it with that many spaces per nesting level — worth reaching for anytime the output is meant for a human, not just another program.
Further reading
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. What's the difference between json.dumps and json.dump?
2. What happens when a Python tuple is serialized to JSON and then deserialized back?
3. Why does json.dumps({1: 'a'}) produce '{"1": "a"}' instead of a JSON object with an integer key?
4. What does the default= parameter of json.dumps do?