Python ships with a json module in the standard library, so working with JSON needs no third-party dependency. It covers the whole round trip: turning JSON text into Python objects and turning Python objects back into JSON text.
This guide walks through the four core functions, the Python-to-JSON type mapping, pretty-printing options, and the parts that trip people up in production: non-ASCII output, serializing datetime and Decimal, and streaming large files as NDJSON. Every section has runnable code. If you also work in the browser, the companion guide Working with JSON in JavaScript covers the same ideas with parse and stringify.
The Four Core Functions: load, loads, dump, dumps
The json module exposes two pairs of functions. The s suffix means "string": loads/dumps work with data in memory, while load/dump work with file-like objects. Get this distinction right and the rest of the module follows.
| Function | Direction | Works with |
|---|---|---|
| json.loads(s) | JSON to Python | a str, bytes, or bytearray |
| json.dumps(obj) | Python to JSON | returns a str |
| json.load(fp) | JSON to Python | a file object |
| json.dump(obj, fp) | Python to JSON | writes to a file object |
import json
# Parse JSON text into a Python object
data = json.loads('{"name": "Ada", "age": 36, "active": true}')
print(data["name"]) # Ada
print(type(data)) # <class 'dict'>
# Serialize a Python object back to JSON text
payload = {"name": "Ada", "roles": ["admin", "dev"], "active": True}
text = json.dumps(payload)
print(text) # {"name": "Ada", "roles": ["admin", "dev"], "active": true}Use the file variants when you are reading or writing a file directly. They avoid materializing the whole document as an intermediate string.
import json
# Read a JSON file
with open("config.json", encoding="utf-8") as f:
config = json.load(f)
# Write a JSON file
with open("out.json", "w", encoding="utf-8") as f:
json.dump(config, f)Parsing JSON from a String, File, or API Response
In real code, JSON arrives from three main places, and each lands on json.loads or json.load.
From an API response
If you use the popular requests library, the response object has a .json() helper that parses the body for you. Without it, decode and parse the text yourself.
import json
import urllib.request
with urllib.request.urlopen("https://api.example.com/users/1") as resp:
body = resp.read().decode("utf-8")
user = json.loads(body)
print(user["email"])
# With requests, this is simply:
# user = requests.get(url).json()Always wrap parsing of untrusted input in try/except. Malformed JSON raises json.JSONDecodeError (a subclass of ValueError), and the exception carries .msg, .lineno, .colno, and .pos so you can report exactly where the problem is.
import json
try:
data = json.loads(raw_text)
except json.JSONDecodeError as e:
print(f"Invalid JSON at line {e.lineno}, column {e.colno}: {e.msg}")How Python Types Map to JSON
Serialization is a type translation. Knowing the mapping explains why True becomes true and why a tuple comes back as a list. JSON has only six value types, so Python's richer type system gets flattened on the way out.
| Python | JSON |
|---|---|
| dict | object |
| list, tuple | array |
| str | string |
| int, float | number |
| True / False | true / false |
| None | null |
Decoding runs the mapping in reverse: JSON objects become dict, arrays become list, true/false become True/False, and null becomes None. The asymmetry to remember is the tuple — it serializes to a JSON array, and no JSON type decodes back to a tuple, so it returns as a list. For the full breakdown of JSON's value types, see JSON Data Types Explained.
Pretty-Printing with indent and sort_keys
By default json.dumps puts everything on one line, using , and : as separators. For human-readable output, pass indent. For stable, diff-friendly output, add sort_keys=True.
import json
data = {"name": "Ada", "roles": ["admin", "dev"], "active": True}
print(json.dumps(data, indent=2, sort_keys=True))
# {
# "active": true,
# "name": "Ada",
# "roles": [
# "admin",
# "dev"
# ]
# }Adopt sort_keys=True whenever JSON output is committed to version control or compared across runs: sorted keys mean the same data always produces byte-identical text, so diffs show real changes instead of reordering noise.
Going the other direction, to shrink JSON for transport, pass a tight separators tuple to strip the whitespace dumps adds by default:
import json
json.dumps(data, separators=(",", ":"))
# {"name":"Ada","roles":["admin","dev"],"active":true}For more on when compact versus indented output matters, see How to Format and Beautify JSON and How to Minify JSON.
ensure_ascii and Unicode Output
This one surprises almost everyone. By default ensure_ascii=True, so every non-ASCII character is escaped as a \uXXXX sequence. The result is valid JSON and safe for any transport, but it is unreadable for non-Latin text.
import json
data = {"city": "Zürich", "emoji": "🚀"}
print(json.dumps(data))
# {"city": "Zürich", "emoji": "🚀"}
print(json.dumps(data, ensure_ascii=False))
# {"city": "Zürich", "emoji": "🚀"}Set ensure_ascii=False when you want readable output — but then you must control the encoding yourself. When writing to a file, open it with encoding="utf-8"; otherwise Python may fall back to a platform encoding that cannot represent the characters and raise a UnicodeEncodeError.
import json
with open("out.json", "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)Custom Encoders: datetime, Decimal, and default=
The module knows how to serialize the built-in JSON types and nothing else. Hand it a datetime, Decimal, set, UUID, or your own class and it raises TypeError: Object of type ... is not JSON serializable. The fix is the default parameter: a function called for any object json cannot handle. Return something serializable, or raise TypeError to reject it.
import json
from datetime import datetime, date
from decimal import Decimal
from uuid import UUID
def json_default(obj):
if isinstance(obj, (datetime, date)):
return obj.isoformat()
if isinstance(obj, Decimal):
return float(obj) # or str(obj) to preserve exact precision
if isinstance(obj, UUID):
return str(obj)
if isinstance(obj, set):
return sorted(obj)
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
record = {
"id": UUID("12345678-1234-5678-1234-567812345678"),
"created": datetime(2026, 7, 12, 9, 30),
"price": Decimal("19.99"),
"tags": {"python", "json"},
}
print(json.dumps(record, default=json_default, indent=2))datetime has no JSON type
JSON has no date type, so a datetime must become a string or a number. ISO 8601 via .isoformat() is the standard choice — it is sortable, unambiguous, and parseable everywhere. Remember it is one-way: when you read the JSON back, "2026-07-12T09:30:00" returns as a plain str, which you convert with datetime.fromisoformat() yourself.
Decimal loses precision when cast to float
Converting Decimal to float reintroduces the binary floating-point rounding you used Decimal to avoid. For money and other exact values, serialize with str(obj) instead, and read it back with json.loads(text, parse_float=Decimal) so numbers become Decimal rather than float.
import json
from decimal import Decimal
text = '{"total": 19.99, "tax": 1.60}'
exact = json.loads(text, parse_float=Decimal)
print(exact["total"]) # Decimal('19.99') — exact decimal, no binary rounding
print(type(exact["total"])) # <class 'decimal.Decimal'>Reading and Writing NDJSON Line by Line
NDJSON (newline-delimited JSON) puts one complete JSON object on each line. It is the standard format for logs, data exports, and streaming APIs because you can process one record at a time without loading a multi-gigabyte file into memory. Crucially, the whole file is not one JSON document — each line is, so you call json.loads per line, not json.load on the file.
import json
def read_ndjson(path):
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line: # skip blank lines
continue
yield json.loads(line)
for event in read_ndjson("events.ndjson"):
print(event["id"], event["type"])Because read_ndjson is a generator, memory stays flat no matter how large the file is — only one parsed record exists at a time. Writing NDJSON mirrors this: dump each object with dumps and append a newline. Keep ensure_ascii and separators consistent so every line is uniform.
import json
with open("events.ndjson", "w", encoding="utf-8") as f:
for event in events:
f.write(json.dumps(event, ensure_ascii=False))
f.write("\n")NDJSON is one of several JSON variants worth knowing. For how it compares to JSON5 and JSONC, see JSON5, JSONC, and NDJSON Explained.