Quick answer: Python's built-in
jsonmodule has four functions.json.loads()parses a string andjson.load()parses an open file;json.dumps()returns a JSON string andjson.dump()writes to an open file. The trailingsmeans string. Addindent=2for readable output and adefault=function to serialize types JSON does not know, such asdatetime.
You call json.load("settings.json"), Python answers with AttributeError: 'str' object has no attribute 'read', and you spend ten minutes convinced the file is corrupt. It is not. The function wanted a file object, not a path, and the sibling with the s wanted a string. That one letter is the source of most first-week confusion with the module, so it is where this guide starts.
Explore the resulting JSON in the JSON Viewer, or select nested fields with the JSONPath Query Tester.
load vs loads, dump vs dumps: The One-Letter Rule
The s suffix stands for string. Functions with it work on strings in memory; functions without it work on file objects. Everything else about the pair is identical, including every keyword argument.
| Function | Input | Returns | Typical use |
|---|---|---|---|
json.load(fp) | File object (opened for reading) | Python object | Read a .json file |
json.loads(s) | str, bytes, or bytearray | Python object | Parse an API response body |
json.dump(obj, fp) | Python object + file object | Nothing (writes to file) | Save config to disk |
json.dumps(obj) | Python object | str | Build a request body or log line |
A memory trick that sticks: load and dump talk to a file, loads and dumps talk to a string. If you are holding a path, you need open() first.
Reading JSON From a File With json.load()
import json
with open("settings.json", encoding="utf-8") as f:
settings = json.load(f)
print(settings["theme"]) # "dark"
print(type(settings)) # <class 'dict'>
Two details matter here. The with block closes the file even if parsing fails. And encoding="utf-8" is not optional on Windows, where the default encoding is often a legacy code page; without it, any non-ASCII character in the file can raise UnicodeDecodeError before the JSON parser even starts.
If the file might have a byte order mark (common when it was saved from Notepad or exported by PowerShell), use encoding="utf-8-sig" instead. Plain utf-8 leaves the BOM in place and the parser fails on the invisible first character.
Parsing a JSON String With json.loads()
This is the function for API bodies, message queue payloads, and anything already in a variable. json.loads() accepts str, bytes, and bytearray, so you can pass a raw HTTP body without decoding it yourself:
import json
raw = '{"id": 42, "tags": ["new", "sale"], "price": 19.99, "stock": null}'
item = json.loads(raw)
item["id"] # 42 (int)
item["tags"] # ['new', 'sale'] (list)
item["price"] # 19.99 (float)
item["stock"] # None
One thing people expect but do not get: json.loads() parses exactly one JSON value. If the string contains two objects back to back, or a trailing comment, you get JSONDecodeError: Extra data. Formats like JSON Lines need a loop that calls loads() once per line.
Writing JSON With json.dump() and json.dumps()
By default the output is compact and on one line, which is right for network payloads and wrong for anything a person reads. The three keyword arguments you will reach for most:
import json
report = {"name": "Zoë", "total": 3, "ok": True, "items": [1, 2, 3]}
json.dumps(report)
# {"name": "Zo\u00eb", "total": 3, "ok": true, "items": [1, 2, 3]}
json.dumps(report, indent=2, sort_keys=True, ensure_ascii=False)
# {
# "items": [
# 1,
# 2,
# 3
# ],
# "name": "Zoë",
# "ok": true,
# "total": 3
# }
indent=2(or 4) pretty-prints with nested indentation. Note that lists are expanded one element per line; there is no built-in option to keep short arrays compact.sort_keys=Trueorders keys alphabetically at every level. Use it for files under version control so diffs only show real changes. The Sort Keys option in JSON Prettifier does the same thing when you are comparing output by eye.ensure_ascii=Falsekeeps non-ASCII characters literal instead of escaping them as\uXXXX. Pair it withencoding="utf-8"on the file.
Writing to disk is the same call with a file object:
with open("report.json", "w", encoding="utf-8") as f:
json.dump(report, f, indent=2, ensure_ascii=False)
f.write("\n") # trailing newline keeps git and linters happy
For the smallest possible output, pass separators=(",", ":") to drop the space after each comma and colon. That is all minifying JSON amounts to.
Type Mapping: What Python Becomes and What Comes Back
Encoding (Python to JSON):
| Python | JSON |
|---|---|
dict | object |
list, tuple | array |
str | string |
int, float | number |
True / False | true / false |
None | null |
Decoding (JSON to Python):
| JSON | Python |
|---|---|
object | dict |
array | list |
string | str |
number (no fraction or exponent) | int |
number (with fraction or exponent) | float |
true / false | True / False |
null | None |
The round trip is lossy in a few places. A tuple comes back as a list. Dict keys that were integers come back as strings, because JSON object keys are always strings: json.loads(json.dumps({1: "a"})) returns {'1': 'a'}. And float("nan") is written as the bare word NaN, which is valid for Python but not for the JSON spec, so other parsers reject it. Pass allow_nan=False to make Python raise instead of producing invalid output.
Serializing datetime, Decimal, and Custom Classes With default=
The error everyone hits eventually:
>>> import json
>>> from datetime import datetime
>>> json.dumps({"when": datetime.now()})
TypeError: Object of type datetime is not JSON serializable
JSON has no date type, so the module will not guess. The default argument is a function the encoder calls for any object it cannot handle. Return something serializable or raise TypeError:
import json
from datetime import datetime, date
from decimal import Decimal
from uuid import UUID
def to_json(obj):
if isinstance(obj, (datetime, date)):
return obj.isoformat() # "2026-02-02T09:30:00"
if isinstance(obj, Decimal):
return str(obj) # keeps exact digits
if isinstance(obj, UUID):
return str(obj)
if isinstance(obj, set):
return sorted(obj)
raise TypeError(f"Cannot serialize {type(obj).__name__}")
json.dumps({"when": datetime(2026, 2, 2, 9, 30), "amount": Decimal("19.99")}, default=to_json)
# {"when": "2026-02-02T09:30:00", "amount": "19.99"}
ISO 8601 strings are the convention for dates in JSON; the JSON date format guide explains why and how to include a timezone. For Decimal, a string is the safe choice because converting to float silently loses precision on money values.
Dataclasses are a two-liner: default=lambda o: dataclasses.asdict(o). For larger projects, aJSONEncoder subclass with an overridden default() method, passed as cls=MyEncoder, does the same job in a reusable form.
Custom Decoding With object_hook and parse_float
Decoding has the mirror image. object_hook is called with every decoded dict, innermost first, and whatever it returns replaces that dict:
import json
from datetime import datetime
def revive(d):
if "when" in d:
d["when"] = datetime.fromisoformat(d["when"])
return d
event = json.loads('{"name": "deploy", "when": "2026-02-02T09:30:00"}', object_hook=revive)
event["when"].year # 2026
Two related hooks are worth knowing. parse_float=Decimal makes every JSON number with a fraction arrive as a Decimal, which is the correct way to read prices. And object_pairs_hook receives a list of (key, value) pairs instead of a dict, so you can detect duplicate keys that a plain dict would silently overwrite.
Handling JSONDecodeError
Malformed input raises json.JSONDecodeError, a subclass of ValueError. The exception carries the position, which is the part worth logging:
import json
text = '{"a": 1,\n "b": [1, 2,]}'
try:
json.loads(text)
except json.JSONDecodeError as e:
print(e.msg) # Expecting value
print(e.lineno) # 2
print(e.colno) # 13
print(e.pos) # 21 (character offset from the start)
lineno and colno are 1-based, matching what editors show. The messages are terse but consistent: Expecting value usually means a trailing comma or empty input, Expecting property name enclosed in double quotes means single quotes or a trailing comma before }, and Extra data means something follows the first complete value. If the text is more than a few lines, pasting it into JSON Prettifier gives you the same line and column with the offending character highlighted, which is faster than counting.
Common Mistakes
1. Passing a path to json.load()
json.load("data.json")
# AttributeError: 'str' object has no attribute 'read'
load() wants a file object. Either open the file first or, for a one-liner, use json.loads(Path("data.json").read_text(encoding="utf-8")).
2. Feeding Python syntax to json.loads()
json.loads("{'name': 'Ada', 'active': True, 'manager': None}")
# JSONDecodeError: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)
That string is a Python dict literal, not JSON: single quotes, True, and None are all invalid. If you really have a Python literal in a string, ast.literal_eval() parses it safely. The common JSON errors guide covers the rest of this family.
3. Using json.dumps() where you meant print()
json.dumps(obj) returns a string and does nothing else. If nothing appears in your terminal, you forgot to print it. Conversely, json.dump(obj, f) returns None, so assigning its result to a variable gives you nothing useful.
4. Writing with the default encoding
open("out.json", "w") without encoding= uses the platform default. On Linux and macOS that is UTF-8; on Windows it is frequently not. Always pass encoding="utf-8" for both reading and writing.
5. Expecting dumps() to be a deep copy
json.loads(json.dumps(obj)) does produce an independent copy, but it turns tuples into lists, integer keys into strings, and drops anything your default= converted. Use copy.deepcopy() when you want a real copy.
A Pattern Worth Copying
A small pair of helpers that bakes in the right defaults for most projects:
import json
from pathlib import Path
def read_json(path):
return json.loads(Path(path).read_text(encoding="utf-8-sig"))
def write_json(path, data):
text = json.dumps(data, indent=2, ensure_ascii=False, sort_keys=True, default=str)
Path(path).write_text(text + "\n", encoding="utf-8")
default=str is a pragmatic escape hatch: anything the encoder cannot handle gets its str() form, which covers datetime, Decimal, UUID, and Path well enough for logs and config files. For API contracts, write an explicit function so a new type cannot sneak through in a surprising format. If you are coming from JavaScript, the JSON.parse() and JSON.stringify() guide shows the same ideas with the equivalent arguments.