What you'll learn
Quick Answer
json.load reads from a file object and json.loads reads from a string; json.dump writes to a file object and json.dumps returns a string. The trailing s means string. JSON only knows objects, arrays, strings, numbers, booleans and null, so datetime, set, Decimal and tuple do not survive a round trip unchanged. Pass ensure_ascii=False when writing Indian-language text, and always catch json.JSONDecodeError when parsing anything from a network.
The four functions and the s that trips people
The json module in the standard library has essentially four functions, and the difference between each pair is one letter.
json.loads takes a string and gives you Python objects. json.load takes a file object and does the same. On the writing side, json.dumps returns a string and json.dump writes straight into a file object. The mnemonic is that the trailing s stands for string, not for plural.
import json
text = '{"city": "Pune", "fee": 199}'
data = json.loads(text) # string in
print(data["fee"] + 1) # 200, a real int
with open("fees.json", "r", encoding="utf-8") as f:
data = json.load(f) # file object in
with open("fees.json", "w", encoding="utf-8") as f:
json.dump(data, f) # file object outMixing them up produces error messages that do not point at the real problem. Passing a file object to loads gives TypeError: the JSON object must be str, bytes or bytearray, not TextIOWrapper. Passing a string to load gives AttributeError: 'str' object has no attribute 'read'. Both mean the same thing: you picked the wrong one of the pair.
Also worth internalising is the type mapping, because it explains most later surprises. JSON objects become Python dicts, arrays become lists, true and false become True and False, null becomes None, numbers become int or float. That list is the whole of JSON. Anything else in your Python data has to be converted by you before it can be written.
Always pass encoding="utf-8" when opening the file. On Linux and macOS the default is usually UTF-8 anyway, but on Windows the default text encoding has historically been the system code page, so the same script that works on your college lab machine can throw UnicodeDecodeError on somebody else's laptop.
indent, sort_keys and the ensure_ascii trap
By default json.dumps puts everything on a single line, with a space after each comma and each colon. That is compact enough for a network payload and unreadable for a config file a human will edit or a file you will commit to git.
import json
course = {"title": "DSA", "fee": 199, "cities": ["Pune", "Kochi"]}
print(json.dumps(course))
# {"title": "DSA", "fee": 199, "cities": ["Pune", "Kochi"]}
print(json.dumps(course, indent=2, sort_keys=True))
# {
# "cities": [
# "Pune",
# "Kochi"
# ],
# "fee": 199,
# "title": "DSA"
# }sort_keys=True is quietly valuable for anything checked into version control, because otherwise a re-save can reorder keys and produce a diff full of noise that hides the one real change.
Now the trap that hits Indian projects immediately. ensure_ascii defaults to True, which escapes every non-ASCII character.
import json
row = {"greeting": "नमस्ते", "price": "₹199"}
print(json.dumps(row))
# {"greeting": "\u0928\u092e\u0938\u094d\u0924\u0947", "price": "\u20b9199"}
print(json.dumps(row, ensure_ascii=False))
# {"greeting": "नमस्ते", "price": "₹199"}Both forms are valid JSON and both parse back to exactly the same string, so nothing is broken. But the escaped version is unreadable in a code review, bloats the file, and convinces people that their Hindi, Tamil or Bengali content was corrupted when it was not. If you write ensure_ascii=False, you must also open the file with encoding="utf-8", otherwise the write itself can fail on a system whose default encoding cannot represent those characters.
One more small thing: for compact network output, pass separators=(",", ":") to drop the space after each comma and colon. It is the standard way to shave bytes off an API response.
datetime, sets and everything JSON cannot hold
The most common serialisation error in Python web code is one line long:
import json
from datetime import datetime
json.dumps({"created": datetime.now()})
# TypeError: Object of type datetime is not JSON serializableJSON has no date type. It has no set, no Decimal, no bytes, no UUID and no custom class. You have to choose a representation, and the sane choice for time is an ISO 8601 string, because it sorts correctly as text and every language can parse it.
import json
from datetime import datetime, date
from decimal import Decimal
def encode(obj):
if isinstance(obj, (datetime, date)):
return obj.isoformat()
if isinstance(obj, Decimal):
return str(obj) # str keeps exact digits, float would not
if isinstance(obj, set):
return sorted(obj)
raise TypeError(f"{type(obj).__name__} is not JSON serializable")
payload = {"created": datetime.now(), "fee": Decimal("199.00")}
print(json.dumps(payload, default=encode))The default= hook is called only for objects the encoder cannot handle itself, so it costs nothing on ordinary data. Raising TypeError at the end matters: if you return a placeholder string instead, unknown types silently turn into garbage and you find out in production.
Be aware that the round trip is lossy in ways nobody warns you about. Tuples in values come back as lists. Dictionary keys that were integers come back as strings, because JSON object keys are always strings:
import json
print(json.loads(json.dumps({1: "a", 2: "b"})))
# {'1': 'a', '2': 'b'} <- int keys are now str, silently
print(json.loads(json.dumps({"point": (2, 3)})))
# {'point': [2, 3]} <- tuple came back a listNeither of those raises anything, which is what makes them dangerous: a dict keyed by integers comes back keyed by strings, and the next data[1] lookup fails with a KeyError that points nowhere useful. Keys of a type JSON cannot represent at all are rejected outright instead, with TypeError: keys must be str, int, float, bool or None, not tuple. Either way, if you need integer or tuple keys, convert them explicitly before dumping and convert them back after loading.
To go the other way, object_hook receives every decoded object and lets you rebuild richer types, for example turning any value that parses as an ISO timestamp back into a datetime.
Reading JSON from a real API
With requests, calling .json() on the response does the parsing for you. The mistake is trusting it.
import json, requests
r = requests.get("https://api.example.com/courses", timeout=10)
if r.status_code != 200:
raise SystemExit(f"API said {r.status_code}: {r.text[:200]}")
try:
data = r.json()
except json.JSONDecodeError:
raise SystemExit(f"Not JSON. First 200 chars: {r.text[:200]}")When an API fails, it very often does not fail in JSON. A gateway timeout returns an HTML error page. A misconfigured proxy returns a login redirect. A rate limiter returns plain text. Your parser then reports Expecting value: line 1 column 1 (char 0), which reads like a bug in your code and is actually the server saying no. Printing the first couple of hundred characters of the raw body turns that mystery into an obvious answer in seconds.
Once parsed, treat the structure as untrusted. Fields go missing, become null, or change type between API versions. Chained indexing like data["result"][0]["name"] raises KeyError, IndexError or TypeError depending on which part broke.
items = data.get("result") or []
first = items[0] if items else {}
name = first.get("name", "unknown")Two more practical habits. Always set a timeout, because without one a stalled connection can hang your script indefinitely. And when an API returns a large list, prefer streaming or pagination over loading a giant document into memory; json.load builds the entire structure at once, and a tree of Python dicts, lists and strings takes up considerably more room than the same data did on disk. For those cases a line-delimited JSON format, one object per line parsed with json.loads in a loop, is far kinder.
JSONDecodeError and the edge cases that bite
json.JSONDecodeError is a subclass of ValueError, and it carries the position of the failure, which is much more useful than the message alone.
import json
bad = '{"city": "Pune",}'
try:
json.loads(bad)
except json.JSONDecodeError as e:
print(e.msg) # e.g. Illegal trailing comma before end of object
print(e.lineno, e.colno, e.pos) # 1 16 15The wording of e.msg was reworded in recent Python releases, so branch on the exception type and the position rather than matching the message text. The usual causes are boringly consistent. Trailing commas are legal in Python literals and illegal in JSON. Single quotes are legal in Python and illegal in JSON, so a Python dict printed with print(d) is not valid JSON and cannot be parsed back. Comments do not exist in JSON at all, which is why editing a config file and adding a helpful // note breaks it.
Python is also more permissive than the JSON specification in one direction, and that asymmetry causes real interoperability bugs:
import json
print(json.dumps({"score": float("nan")}))
# {"score": NaN} <- not valid JSON, other parsers will reject it
json.dumps({"score": float("nan")}, allow_nan=False)
# ValueError: Out of range float values are not JSON compliantIf your data pipeline ever produces NaN or inf, typically from pandas or a division, the Python side writes it happily and the JavaScript or Java side on the other end fails to parse. Pass allow_nan=False so the failure happens in your code, where you can fix it, instead of in someone else's.
Two last details. Duplicate keys are not an error; the last one silently wins, so a hand-edited file with a key written twice loses data without complaint. And very large integers survive Python's parser fine but lose precision in JavaScript, so IDs beyond roughly nine quadrillion should be sent as strings if a browser will read them.
