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 out

Mixing 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 serializable

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

Neither 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 15

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

If 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.

Frequently Asked Questions

What is the difference between json.load and json.loads? json.loads parses a JSON string that is already in memory, while json.load reads from an open file object and parses whatever it reads. The same distinction applies to json.dump and json.dumps on the writing side. The trailing s means string. If you accidentally pass a file object to loads you get a TypeError about needing str or bytes, and passing a string to load raises an AttributeError about a missing read method.
Why do I get 'Object of type datetime is not JSON serializable'? JSON has no date or time type, so the encoder does not know what to write. Convert the value yourself, usually with .isoformat() to produce an ISO 8601 string, or pass a default= function to json.dumps that handles datetime, Decimal, set and any other custom types. Remember that parsing brings it back as a plain string, so you must convert it to a datetime again on the way in.
How do I stop Python escaping Hindi or Tamil text in JSON? Pass ensure_ascii=False to json.dumps or json.dump, and open the output file with encoding='utf-8'. By default the encoder escapes every non-ASCII character into \u sequences, which is valid JSON and parses back to exactly the same string but is unreadable in reviews and larger on disk. Nothing is corrupted either way; only the on-disk representation changes.
Why does json.loads fail with 'Expecting value: line 1 column 1'? That message means the very first character was not the start of valid JSON, which almost always means the server sent something else entirely: an HTML error page, a redirect, an empty body, or plain text from a rate limiter. Print the first two hundred characters of the raw response before parsing. Checking the HTTP status code first usually reveals the real problem immediately.
Is a Python dictionary the same as JSON? No. A dict is an in-memory Python object; JSON is a text format. They look similar when printed, but Python uses single quotes, True, False and None, whereas JSON requires double quotes, true, false and null. Printing a dict and trying to parse that text as JSON usually fails. Python dicts also allow tuple and integer keys, which JSON cannot represent.