JSON in Python
JSON is the standard way to move structured data between programs. Four functions cover it, and the type mapping has a few asymmetries worth knowing.
- Basics
- Data Types
- Operators
- Strings
- Control Flow
- Lists
- Tuples
- Sets
- Dictionaries
- Comprehensions
- Functions
- Advanced Functions
- Recursion
- Exception Handling
- File Handling
- Modules
- Standard Library
- OOP
- Advanced OOP
- Iterators and Generators
- Decorators
- Context Managers
- Descriptors and Dataclasses
- Python Internals
- Concurrency
- Regular Expressions
- Serialization
- Command Line Python
- Testing and Debugging
- Type Hints
- Performance
- Python Security
- DSA with Python
The four functions
| Function | Direction | Works with |
|---|---|---|
json.dumps(obj) | Python to JSON | A string |
json.loads(text) | JSON to Python | A string |
json.dump(obj, file) | Python to JSON | A file |
json.load(file) | JSON to Python | A file |
The s means "string". Without it, the function reads from or writes to a file object.
import json
record = {"name": "Meera", "age": 27, "tags": ["staff", "admin"], "active": True}
text = json.dumps(record)
print(text)
print(type(text)) # <class 'str'>
restored = json.loads(text)
print(restored == record) # TrueThe type mapping
| Python | JSON | Back as |
|---|---|---|
dict | object | dict |
list | array | list |
tuple | array | list |
str | string | str |
int | number | int |
float | number | float |
True / False | true / false | bool |
None | null | None |
import json
original = {"point": (1, 2), "count": 3}
restored = json.loads(json.dumps(original))
print(restored) # {'point': [1, 2], 'count': 3}
print(type(restored["point"])) # <class 'list'> - the tuple is goneThe round trip is not always exact. Tuples come back as lists, and dictionary keys that were integers come back as strings, because JSON object keys must be strings.
import json
original = {1: "one", 2: "two"}
restored = json.loads(json.dumps(original))
print(restored) # {'1': 'one', '2': 'two'} - keys are now text
# Restore them deliberately if the keys matter
print({int(k): v for k, v in restored.items()})Formatting the output
import json
data = {"b": 2, "a": 1, "nested": {"y": [1, 2], "x": None}}
print(json.dumps(data)) # compact, one line
print(json.dumps(data, indent=2)) # readable
print(json.dumps(data, indent=2, sort_keys=True)) # stable key order
print(json.dumps(data, separators=(",", ":"))) # smallest possible
print(json.dumps({"city": "पुणे"})) # escaped by default
print(json.dumps({"city": "पुणे"}, ensure_ascii=False)) # readable UnicodeUse indent=2 for anything a person will read, and separators=(",", ":") for anything sent over a network. Use sort_keys=True when the output is compared or committed to version control.
Files
import json
from pathlib import Path
settings = {"theme": "dark", "size": 14, "recent": ["a.txt", "b.txt"]}
with open("settings.json", "w", encoding="utf-8") as handle:
json.dump(settings, handle, indent=2, ensure_ascii=False)
with open("settings.json", encoding="utf-8") as handle:
loaded = json.load(handle)
print(loaded == settings)
# The pathlib one liner, for small files
Path("settings.json").write_text(json.dumps(settings, indent=2), encoding="utf-8")
print(json.loads(Path("settings.json").read_text(encoding="utf-8")))Handling failure
import json
def load_settings(path, defaults=None):
defaults = defaults or {}
try:
with open(path, encoding="utf-8") as handle:
return {**defaults, **json.load(handle)}
except FileNotFoundError:
print(f"{path} not found, using defaults")
return defaults
except json.JSONDecodeError as error:
print(f"{path} is not valid JSON at line {error.lineno}: {error.msg}")
return defaults
print(load_settings("missing.json", {"theme": "light"}))import json
try:
json.loads('{"name": "Meera", }') # a trailing comma is not valid JSON
except json.JSONDecodeError as error:
print(error.msg, "at position", error.pos)JSON is stricter than Python: no trailing commas, no comments, no single quotes, and true, false and null are lowercase. Always catch JSONDecodeError on data you did not write.
Types JSON does not have
import json
from datetime import date, datetime
from decimal import Decimal
data = {"when": date(2026, 8, 22), "amount": Decimal("19.99"), "tags": {"a", "b"}}
# json.dumps(data) # TypeError: Object of type date is not JSON serializableThe default hook
import json
from datetime import date, datetime
from decimal import Decimal
def encode(value):
if isinstance(value, (date, datetime)):
return value.isoformat()
if isinstance(value, Decimal):
return str(value)
if isinstance(value, (set, frozenset)):
return sorted(value)
raise TypeError(f"cannot serialise {type(value).__name__}")
data = {"when": date(2026, 8, 22), "amount": Decimal("19.99"), "tags": {"b", "a"}}
print(json.dumps(data, default=encode, indent=2))A custom encoder class
import json
from dataclasses import dataclass, asdict, is_dataclass
from datetime import date
@dataclass
class Note:
title: str
created: date
tags: list
class Encoder(json.JSONEncoder):
def default(self, value):
if is_dataclass(value):
return asdict(value)
if isinstance(value, date):
return value.isoformat()
return super().default(value)
note = Note("Regex", date(2026, 8, 22), ["python", "text"])
print(json.dumps(note, cls=Encoder, indent=2))Decoding back into objects
import json
from datetime import date
def decode(pairs):
result = dict(pairs)
if "created" in result:
result["created"] = date.fromisoformat(result["created"])
return result
text = '{"title": "Regex", "created": "2026-08-22", "tags": ["python"]}'
record = json.loads(text, object_pairs_hook=decode)
print(record)
print(type(record["created"])) # <class 'datetime.date'>Working with nested JSON
import json
text = """
{
"company": "Lumen Works",
"departments": [
{"name": "engineering", "staff": [
{"name": "Meera", "years": 4},
{"name": "Arun", "years": 2}
]},
{"name": "design", "staff": [{"name": "Sara", "years": 6}]}
]
}
"""
data = json.loads(text)
everyone = [
member
for department in data["departments"]
for member in department["staff"]
]
print(len(everyone), "people")
print(sum(m["years"] for m in everyone), "total years")
print(max(everyone, key=lambda m: m["years"])["name"])
for department in data["departments"]:
names = ", ".join(m["name"] for m in department["staff"])
print(f"{department['name']:<14}{names}")def dig(data, *keys, default=None):
"""Follow a path through nested JSON without raising."""
current = data
for key in keys:
try:
current = current[key]
except (KeyError, IndexError, TypeError):
return default
return current
print(dig(data, "departments", 0, "staff", 1, "name")) # Arun
print(dig(data, "departments", 9, "name", default="-")) # -Streaming large files
import json
# JSON Lines: one complete JSON object per line
lines = [
'{"id": 1, "amount": 100}',
'{"id": 2, "amount": 250}',
'{"id": 3, "amount": 75}',
]
total = 0
for line in lines: # in practice, iterate a file
record = json.loads(line)
total += record["amount"]
print(total)json.load reads the whole document into memory, so a two gigabyte JSON array is a problem. The usual answer is JSON Lines: one object per line, read and parsed one line at a time.
A worked example
import json
from pathlib import Path
class Settings:
"""Settings backed by a JSON file, with defaults and validation."""
DEFAULTS = {
"theme": "light",
"font_size": 12,
"auto_save": True,
"recent_files": [],
}
def __init__(self, path="settings.json"):
self.path = Path(path)
self.data = dict(self.DEFAULTS)
self.load()
def load(self):
if not self.path.exists():
return self
try:
loaded = json.loads(self.path.read_text(encoding="utf-8"))
except json.JSONDecodeError as error:
print(f"ignoring invalid settings: {error.msg}")
return self
for key, value in loaded.items():
if key not in self.DEFAULTS:
print(f"unknown setting ignored: {key}")
continue
if not isinstance(value, type(self.DEFAULTS[key])):
print(f"wrong type for {key}, using the default")
continue
self.data[key] = value
return self
def save(self):
temporary = self.path.with_suffix(".tmp")
temporary.write_text(
json.dumps(self.data, indent=2, sort_keys=True),
encoding="utf-8",
)
temporary.replace(self.path) # atomic
return self
def __getitem__(self, key):
return self.data[key]
def __setitem__(self, key, value):
if key not in self.DEFAULTS:
raise KeyError(f"unknown setting: {key}")
self.data[key] = value
settings = Settings("demo-settings.json")
settings["theme"] = "dark"
settings["recent_files"] = ["notes.txt"]
settings.save()
print(Settings("demo-settings.json").data)Common mistakes
- Confusing
dumpswithdump. Thesmeans a string. - Expecting tuples and sets to survive a round trip.
- Expecting integer dictionary keys to stay integers.
- Writing a trailing comma or a comment and producing invalid JSON.
- Not catching
JSONDecodeErroron external data. - Loading a very large JSON array with
json.load. - Assuming valid JSON means valid data; the structure still needs checking.
Best practices
- Always pass
encoding="utf-8"when opening JSON files. - Use
indent=2for human readable output and compact separators for transport. - Use
sort_keys=Truefor files under version control. - Validate the loaded structure; do not trust its shape.
- Write atomically through a temporary file when replacing a settings file.
- Use JSON Lines for large or streamed data.
Practice
- Round trip a structure containing a tuple, a set and a date, and handle each properly.
- Write a loader that merges a JSON file over a set of defaults and reports unknown keys.
- Parse a nested JSON document and produce a flat report.
- Handle a corrupted JSON file gracefully, reporting the line number.
- Convert a list of dataclass instances to JSON and back.
Conclusion
Four functions, one type mapping and a few asymmetries: tuples become lists, keys become strings, and dates need a hook. Validate anything you did not write, and write settings files atomically.