Python

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.

Beginner

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 object

The 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 keys

JSON 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 now

json.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?