Python Working with JSON
JSON (JavaScript Object Notation) is a lightweight, text-based format for exchanging structured data, and it has become the de facto standard for web APIs, configuration files, and saving structured data to disk. Python’s built-in json module lets you convert native Python objects like dictionaries and lists into JSON text, and parse JSON text back into Python objects, without installing anything extra. Because so many systems speak JSON — REST APIs, config files, NoSQL databases, and other programming languages — reading and writing it fluently in Python is an everyday, essential skill.
Overview: How JSON and the json module work
JSON supports exactly six data types: objects ({}), arrays ([]), strings, numbers, booleans (true/false), and null. Python’s json module maps these onto native Python types automatically, in both directions. When you serialize (encode) a Python value into a JSON string, dictionaries become objects, lists and tuples become arrays, strings stay strings, int/float become numbers, True/False become true/false, and None becomes null. When you deserialize (decode) JSON text back into Python, the reverse mapping happens — but the mapping is lossy in one direction: JSON objects only support string keys, and JSON has no separate “tuple” type, so tuples round-trip as lists.
| Python type | JSON type |
|---|---|
dict |
object |
list, tuple |
array |
str |
string |
int, float |
number |
True / False |
true / false |
None |
null |
The json module gives you four core functions, split along two axes: string vs. file, and encode vs. decode.
json.dumps(obj)— encode a Python object to a JSON string (“dump string”).json.loads(s)— decode a JSON string back into a Python object (“load string”).json.dump(obj, file)— encode a Python object and write it directly to an open file.json.load(file)— read JSON text from an open file and decode it into a Python object.
The “s” suffix is the mnemonic: functions ending in s work with in-memory strings; the ones without it work with file-like objects that support .write() or .read().
Syntax
import json
json.dumps(obj, *, indent=None, sort_keys=False, ensure_ascii=True, default=None, separators=None)
json.loads(s, *, object_hook=None, parse_float=None, parse_int=None)
json.dump(obj, fp, *, indent=None, sort_keys=False, ensure_ascii=True, default=None)
json.load(fp, *, object_hook=None)
| Parameter | Meaning |
|---|---|
indent |
Number of spaces for pretty-printing; None (default) produces compact output on one line. |
sort_keys |
If True, object keys are output in sorted order instead of insertion order. |
ensure_ascii |
If True (default), non-ASCII characters are escaped as \uXXXX; set False to keep raw UTF-8 text. |
default |
A function called for objects that aren’t natively serializable, returning a JSON-friendly replacement. |
object_hook |
A function called on every decoded JSON object (dict), letting you transform it into a custom type. |
fp |
A file-like object opened in text mode, used by dump/load. |
Examples
Example 1: Converting a Python dictionary to JSON and back
import json
person = {
"name": "Ava Patel",
"age": 29,
"is_member": True,
"skills": ["Python", "SQL", "Docker"],
"address": None
}
json_string = json.dumps(person, indent=4)
print(json_string)
parsed_back = json.loads(json_string)
print(type(parsed_back))
print(parsed_back["skills"])
Output:
{
"name": "Ava Patel",
"age": 29,
"is_member": true,
"skills": [
"Python",
"SQL",
"Docker"
],
"address": null
}
['Python', 'SQL', 'Docker']
The dictionary person mixes strings, an integer, a boolean, a list, and None — exactly the types JSON supports. json.dumps(person, indent=4) converts it into a formatted JSON string; notice that Python’s True became the lowercase true and None became null, because JSON’s literals are case-sensitive and different from Python’s. Calling json.loads() on that string reconstructs a brand-new dict — a completely separate object in memory, not the original person — which is why parsed_back["skills"] still contains the same values as the original list.
Example 2: Writing and reading a JSON file
import json
import tempfile
from pathlib import Path
inventory = [
{"item": "Wireless Mouse", "price": 19.99, "in_stock": True},
{"item": "Mechanical Keyboard", "price": 89.50, "in_stock": False},
{"item": "USB-C Hub", "price": 34.25, "in_stock": True},
]
with tempfile.TemporaryDirectory() as tmp_dir:
file_path = Path(tmp_dir) / "inventory.json"
with file_path.open("w", encoding="utf-8") as f:
json.dump(inventory, f, indent=2)
with file_path.open("r", encoding="utf-8") as f:
loaded_inventory = json.load(f)
total_value = sum(item["price"] for item in loaded_inventory if item["in_stock"])
print(f"Loaded {len(loaded_inventory)} items")
print(f"Total value of in-stock items: ${total_value:.2f}")
Output:
Loaded 3 items
Total value of in-stock items: $54.24
This example simulates a small inventory system. json.dump() writes the list of dictionaries straight to an open file handle — you never have to manually call write() on the resulting string yourself. Opening the file with encoding="utf-8" avoids surprises on platforms whose default encoding isn’t UTF-8. json.load() then reads the file back into a fresh Python list; because JSON round-trips numbers as numbers and booleans as booleans, you can immediately use the loaded data in a calculation like the total_value sum without any manual type conversion. The temporary directory is removed automatically when the with block ends, so the example is fully self-contained.
Example 3: Serializing custom objects with default and object_hook
import json
from dataclasses import dataclass, asdict
@dataclass
class Task:
title: str
priority: int
done: bool = False
def task_encoder(obj):
if isinstance(obj, Task):
return {"__task__": True, **asdict(obj)}
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
def task_decoder(data):
if data.get("__task__"):
return Task(title=data["title"], priority=data["priority"], done=data["done"])
return data
tasks = [Task("Write lesson", 1), Task("Review PR", 2, done=True)]
encoded = json.dumps(tasks, default=task_encoder, indent=2)
print(encoded)
decoded = json.loads(encoded, object_hook=task_decoder)
print(decoded)
print(decoded[0].title, decoded[0].priority)
Output:
[
{
"__task__": true,
"title": "Write lesson",
"priority": 1,
"done": false
},
{
"__task__": true,
"title": "Review PR",
"priority": 2,
"done": true
}
]
[Task(title='Write lesson', priority=1, done=False), Task(title='Review PR', priority=2, done=True)]
Write lesson 1
Not every Python object maps onto a JSON type — here, Task is a custom dataclass, and json.dumps() has no idea how to serialize it by default. The default parameter is the escape hatch: whenever the encoder hits an object it doesn’t recognize, it calls your function and serializes whatever that function returns instead. Here task_encoder converts each Task into a plain dict tagged with a "__task__" marker. On the way back in, object_hook is called for every JSON object as it’s parsed, letting task_decoder recognize the marker and rebuild real Task instances rather than leaving them as plain dictionaries.
Under the Hood: What Python Actually Does
Internally, json.dumps() builds a json.JSONEncoder instance and calls its encode() method, which walks your object graph recursively: for a dict it emits {, recursively encodes each key/value pair, then emits }; for a list it does the same with [/]. Because this is a depth-first walk, deeply nested structures are encoded (and decoded) recursively, and CPython ships an optional C accelerator module (_json) that the pure-Python json module uses automatically when available, so encoding and decoding large payloads is fast without any code changes on your part.
Decoding works the opposite way: json.loads() builds a json.JSONDecoder, which uses a recursive-descent scanner to tokenize the text (looking for {, [, string literals, numbers, and the keywords true/false/null) and assembles nested Python dict/list objects as it goes. Since Python 3.7, dictionaries preserve insertion order, and json.loads() takes advantage of that: keys appear in your parsed dict in the same order they appeared in the source text, unless you explicitly re-sort them. One subtlety worth internalizing: JSON object keys are always strings. If you serialize a dict with non-string keys — integers, floats, even True/False/None — json.dumps() silently converts them to their string form (1 becomes "1", True becomes "true"), and there is no automatic way to recover the original key type on the way back in.
Common Mistakes
It’s easy to assume a round-trip through JSON preserves your data exactly, but dictionary keys are a common trap. JSON has no concept of an integer key — every key in a JSON object is a string — so json quietly stringifies non-string keys when encoding:
import json
scores = {1: "Alice", 2: "Bob"}
serialized = json.dumps(scores)
restored = json.loads(serialized)
print(scores == restored)
print(list(restored.keys()))
Output:
False
['1', '2']
After the round trip, restored has the string keys "1" and "2" instead of the integers 1 and 2, so scores == restored is False and looking up restored[1] would raise a KeyError. If you need integer keys back, convert them explicitly after loading, e.g. {int(k): v for k, v in restored.items()}.
The second common mistake is passing an unsupported type straight into json.dumps() — for example a datetime object, a set, or a custom class instance. Code like json.dumps({"when": datetime.now()}) raises TypeError: Object of type datetime is not JSON serializable, because the encoder has no built-in rule for it. The fix is to tell the encoder how to convert the value, either with a small default function or by converting it yourself before encoding:
import json
from datetime import datetime
event = {
"name": "Launch",
"when": datetime(2026, 7, 18, 15, 30),
}
serialized = json.dumps(event, default=str)
print(serialized)
Output:
{"name": "Launch", "when": "2026-07-18 15:30:00"}
Here default=str tells the encoder to fall back to calling str() on anything it can’t handle natively, which works nicely for datetime objects. For more control — a specific date format, or several unsupported types at once — write your own conversion function, as in Example 3, instead of relying on str().
Best Practices
- Always specify
encoding="utf-8"explicitly when opening files forjson.dump/json.load, rather than relying on the platform’s default encoding. - Use
indent=2orindent=4for files meant to be read or diffed by humans (config files, fixtures); leaveindent=None(the default) for compact machine-to-machine payloads. - Wrap
json.loads()/json.load()in atry/except json.JSONDecodeErrorblock whenever the JSON comes from an untrusted or external source, rather than assuming it’s always well-formed. - Prefer the
defaultparameter or a customjson.JSONEncodersubclass over manually pre-converting your whole object graph into plain dicts before serializing. - Set
ensure_ascii=Falsewhen you know your output stream or file is UTF-8 and contains non-English text — it keeps the JSON human-readable instead of escaping every accented character. - Never use
eval()to parse JSON-like strings;json.loads()is faster, safer, and doesn’t execute arbitrary code. - Use
sort_keys=Truewhen writing JSON that will be committed to version control or diffed, so unrelated re-serializations don’t produce noisy diffs. - For very large JSON files that don’t comfortably fit in memory, look into streaming parsers such as the third-party
ijsonlibrary instead of a singlejson.load()call.
Practice Exercises
- Write a function
save_config(config, path)that writes a dictionary to a JSON file with 2-space indentation and sorted keys, plus a matchingload_config(path)function that reads it back. Test them by round-tripping a small settings dictionary. - You have a list of Python
setobjects that you need to store as JSON. Since JSON has no set type, write adefaultfunction that converts anysetinto a sorted list, and use it withjson.dumps(). - Given the JSON string
'{"user": {"name": "Sam", "orders": [{"id": 1, "total": 42.5}, {"id": 2, "total": 17.0}]}}', parse it withjson.loads()and print the sum of all order totals. (Expected result:59.5.)
Summary
json.dumps()/json.loads()convert between Python objects and JSON strings;json.dump()/json.load()do the same directly with files.- JSON only has six data types, and Python’s
jsonmodule maps them ontodict,list,str,int/float,bool, andNoneautomatically in both directions. - Dictionary keys are always stringified in JSON — non-string keys don’t round-trip to their original type without extra conversion.
- Use the
defaultparameter (andobject_hookon decode) to teach the encoder/decoder how to handle custom objects that aren’t natively JSON-serializable. - Always guard
json.loads()/json.load()calls on external data with atry/except json.JSONDecodeError. - Use
indent,sort_keys, andensure_asciito control the readability, diff-friendliness, and Unicode handling of your output.
