JSON
Converting between Python objects and JSON text with the json module — the standard format for configuration, APIs, and data interchange.
JSON (JavaScript Object Notation) is a lightweight, language-independent text format for structured data, and it's the de facto standard for web APIs, configuration files, and cross-language data interchange. Python's json module converts between Python objects and JSON text in both directions, with a small, predictable set of type mappings and hooks for extending it to custom objects.
What Is JSON and Why It Matters
JSON represents data as nested objects ({}, like Python dicts), arrays ([], like Python lists), strings, numbers, booleans (true/false), and null. Because nearly every programming language and web platform can read and write it, JSON is the common format for REST APIs, config files, log records, and saved application state. Python's json module (standard library, no installation needed) handles the conversion in both directions.
Basic Syntax: dumps and loads (Strings)
json.dumps(obj) serializes a Python object to a JSON-formatted str. json.loads(s) parses a JSON string back into Python objects. These are the string-based, in-memory functions — use them when you already have the JSON as a string (e.g. from an HTTP response body).
import json
data = {"name": "Ada", "age": 36, "active": True, "tags": ["engineer", "math"]}
text = json.dumps(data)
print(text)
# '{"name": "Ada", "age": 36, "active": true, "tags": ["engineer", "math"]}'
parsed = json.loads(text)
print(parsed["name"], parsed["tags"][0]) # Ada engineer
print(type(parsed)) # <class 'dict'>dump and load (Files)
json.dump(obj, fp) and json.load(fp) are the file-object counterparts — they write to (or read from) an already-open file object directly, without you manually handling an intermediate string.
Mnemonic: the functions with an 's' (dumps/loads) work with strings; the ones without work with file objects (dump/load to/from a stream).
import json
data = {"name": "Ada", "age": 36}
with open("person.json", "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
with open("person.json", encoding="utf-8") as f:
loaded = json.load(f)
print(loaded) # {'name': 'Ada', 'age': 36}JSON-Compatible Types and the Mapping Table
json only knows how to serialize a fixed set of Python types out of the box. Anything outside this set raises TypeError unless you supply custom serialization (see below).
Python JSON
----------------- ----------
dict object {}
list, tuple array []
str string
int, float number
True / False true / false
None null
JSON -> Python (json.loads) reverses this, with one asymmetry:
JSON arrays always become Python lists — a tuple serialized out comes back as a list, not a tuple.import json
print(json.dumps((1, 2, 3))) # '[1, 2, 3]' — tuple becomes a JSON array
print(json.loads("[1, 2, 3]")) # [1, 2, 3] — always comes back as a list, never a tupleFormatting Output: indent, sort_keys, separators
By default dumps/dump produce compact single-line output. indent produces human-readable, multi-line, indented JSON (common for config files and debugging); sort_keys=True sorts dict keys alphabetically (useful for deterministic diffs); separators controls the item/key separators explicitly, most often to shave whitespace for compact machine-to-machine output.
import json
data = {"b": 2, "a": 1}
print(json.dumps(data))
# {"b": 2, "a": 1}
print(json.dumps(data, indent=2, sort_keys=True))
# {
# "a": 1,
# "b": 2
# }
print(json.dumps(data, separators=(",", ":"))) # no spaces — smallest output
# {"b":2,"a":1}Custom Serialization: default and Encoders
For objects json doesn't know how to serialize natively (dates, sets, custom classes), pass a default function to dumps/dump. It's called with the un-serializable object and must return something JSON-compatible (or raise TypeError for anything it truly can't handle).
For deeper control (e.g. reusable across many calls), subclass json.JSONEncoder and override default, then pass cls=YourEncoder to dumps.
import json
from datetime import date, datetime
def json_default(obj):
if isinstance(obj, (date, datetime)):
return obj.isoformat()
if isinstance(obj, set):
return sorted(obj)
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
record = {"created": date(2024, 1, 15), "tags": {"python", "json"}}
print(json.dumps(record, default=json_default))
# {"created": "2024-01-15", "tags": ["json", "python"]}import json
from datetime import date
class DateAwareEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, date):
return obj.isoformat()
return super().default(obj)
print(json.dumps({"created": date(2024, 1, 15)}, cls=DateAwareEncoder))Custom Deserialization: object_hook
Going the other way, json.loads/load accept an object_hook function called on every decoded JSON object (dict), letting you convert plain dicts back into richer Python objects as parsing proceeds — for example, turning ISO date strings back into date objects, or building custom class instances.
import json
from datetime import date
def object_hook(d):
if "created" in d:
d["created"] = date.fromisoformat(d["created"])
return d
text = '{"name": "report", "created": "2024-01-15"}'
result = json.loads(text, object_hook=object_hook)
print(result["created"], type(result["created"])) # 2024-01-15 <class 'datetime.date'>Encoding Considerations
JSON text is conventionally UTF-8. json.dumps by default escapes all non-ASCII characters using \uXXXX sequences (ensure_ascii=True) — safe for any transport but harder to read. Set ensure_ascii=False to emit UTF-8 characters directly (readable, and slightly smaller), but then make sure whatever writes the resulting bytes to a file or network stream actually encodes as UTF-8.
Always open files for JSON I/O with an explicit encoding="utf-8" rather than relying on the platform default — this avoids UnicodeDecodeError/UnicodeEncodeError surprises on systems whose default encoding isn't UTF-8 (notably some Windows configurations).
import json
data = {"city": "München"}
print(json.dumps(data)) # {"city": "M\u00fcnchen"}
print(json.dumps(data, ensure_ascii=False)) # {"city": "München"}
with open("city.json", "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False) # write real UTF-8 bytes to diskHandling Malformed JSON
json.loads/load raise json.JSONDecodeError (a subclass of ValueError) on invalid input, with .msg, .lineno, .colno, and .pos attributes pinpointing where parsing failed — useful for reporting exactly what went wrong in malformed input from an external source.
import json
try:
json.loads('{"name": "Ada", }') # trailing comma — invalid JSON
except json.JSONDecodeError as e:
print(f"Invalid JSON at line {e.lineno}, column {e.colno}: {e.msg}")Round-Trip Fidelity Limits
JSON has fewer types than Python, so a round trip through dumps/loads is not always identity-preserving: tuples become lists, dict keys that aren't strings get stringified (JSON object keys must be strings), sets aren't natively supported at all, and NaN/Infinity/-Infinity are emitted as bare (non-standard-JSON) tokens by default, which some strict JSON parsers in other languages will reject.
import json
d = {1: "one", 2: "two"}
text = json.dumps(d)
print(text) # '{"1": "one", "2": "two"}' — int keys became strings
back = json.loads(text)
print(back) # {'1': 'one', '2': 'two'} — keys are now strings, not ints
print(json.dumps(float("nan"))) # 'NaN' — valid Python json output, NOT standard JSONPractical Usage: Config Files and API Payloads
Two of the most common uses: loading a JSON configuration file at startup, and building/parsing request or response bodies for an HTTP API.
json only handles converting Python objects to/from JSON text — actually sending that text over HTTP is a separate concern, typically handled by urllib.request (standard library) or the third-party requests library.
import json
# Loading configuration
with open("config.json", encoding="utf-8") as f:
config = json.load(f)
timeout = config.get("timeout_seconds", 30)
# Building an API request body (the actual HTTP call itself would typically
# use the third-party "requests" library or the standard library's urllib —
# json only handles the serialization step here)
payload = json.dumps({"query": "python json", "limit": 10})Key Takeaways
dumps/loads work with strings; dump/load work with open file objects — same underlying conversion, different I/O target.
Only dict, list/tuple, str, int/float, bool, and None serialize natively; tuples come back as lists, and non-string dict keys are stringified.
Use indent for human-readable output and sort_keys for deterministic, diffable output.
Custom types need a default function (or a JSONEncoder subclass) for serialization and an object_hook for deserialization.
Always open files with encoding="utf-8" explicitly, and consider ensure_ascii=False for readable non-ASCII output.
json.JSONDecodeError (a ValueError subclass) reports the exact line/column of malformed input.
with open("data.json") as f: config = json.load(f) # relies on platform default encoding with open("data.json", encoding="utf-8") as f: config = json.load(f)
Omitting encoding relies on the platform default, which isn't always UTF-8; JSON is conventionally UTF-8, so specify it explicitly to avoid decode errors on some systems.
json.dumps({"created": datetime.now()}) # raises TypeError: not JSON serializable json.dumps({"created": datetime.now()}, default=lambda o: o.isoformat()) json only knows dict/list/str/int/float/bool/None natively; a datetime needs a default function (or custom encoder) to convert it into a JSON-compatible representation.
original = (1, 2, 3) restored = json.loads(json.dumps(original)) assert restored == original # fails — restored is a list, not a tuple original = (1, 2, 3) restored = tuple(json.loads(json.dumps(original))) assert restored == original
JSON has no tuple type; a Python tuple serializes as a JSON array and always deserializes back as a list, so round-trip equality with the original tuple requires an explicit conversion.
What is the difference between json.dumps/loads and json.dump/load?
dumps/loads convert to/from an in-memory Python str. dump/load convert to/from an already-open file object directly (writing/reading a stream), without you handling the intermediate string yourself.
Which Python types does json serialize natively, and what happens when you pass something outside that set?
dict, list/tuple, str, int/float, bool, and None serialize natively. Anything else (like a datetime or a custom class instance) raises TypeError unless you supply a default function or a custom JSONEncoder subclass to handle it.
Why might json.dumps(some_tuple) followed by json.loads() not reproduce the original object?
JSON has no tuple type — only arrays. A Python tuple serializes as a JSON array, and json.loads always deserializes JSON arrays as Python lists, never tuples, so the round trip changes the type even though the values are preserved.
What does the ensure_ascii parameter control, and what's the tradeoff of setting it to False?
ensure_ascii (default True) escapes all non-ASCII characters as \uXXXX sequences in the output. Setting it False emits actual UTF-8 characters directly, which is more readable and slightly smaller, but requires the consumer of the output to correctly handle UTF-8 encoded text.
How do you handle a JSON key that isn't a string, like an integer, when serializing a Python dict?
JSON object keys must be strings, so json.dumps automatically converts non-string dict keys (like ints) to their string representation. This is a one-way conversion — after a round trip through loads, the keys come back as strings, not the original type.
What exception does json.loads raise on malformed input, and what information does it carry?
json.JSONDecodeError (a subclass of ValueError), carrying .msg (description), .lineno and .colno (line/column of the error), and .pos (character offset) to help pinpoint exactly where the input is invalid.
Write a function that serializes a dict containing datetime values to a JSON string, converting each datetime to an ISO 8601 string.
import json from datetime import datetime def to_json(data: dict) -> str: def default(obj): if isinstance(obj, datetime): return obj.isoformat() raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable") return json.dumps(data, default=default) json.dumps calls the default function only for objects it can't serialize natively; checking isinstance(obj, datetime) and returning obj.isoformat() converts it to a JSON-compatible string, while re-raising TypeError for anything else preserves the normal error behavior for truly unsupported types.
What does this code print?
<class 'list'>. The tuple (90, 85, 78) serializes to a JSON array, and json.loads always deserializes JSON arrays as Python lists — there is no way to recover the original tuple type from the JSON text alone.
Write a function that reads a JSON config file and returns a value for a given dotted key path (e.g. 'database.host'), raising KeyError with a clear message if any part of the path is missing.
import json def get_config_value(path: str, key_path: str): with open(path, encoding="utf-8") as f: config = json.load(f) node = config for part in key_path.split("."): if not isinstance(node, dict) or part not in node: raise KeyError(f"Missing key '{part}' in path '{key_path}'") node = node[part] return node json.load parses the whole file into nested dicts/lists; splitting the dotted key path and walking the structure one segment at a time, with an explicit check before each lookup, gives a precise error identifying exactly which segment of the path was missing rather than a generic KeyError.
import json
data = {"scores": (90, 85, 78)}
text = json.dumps(data)
result = json.loads(text)
print(type(result["scores"]))