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/Nonejson.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 for json.dump/json.load, rather than relying on the platform’s default encoding.
  • Use indent=2 or indent=4 for files meant to be read or diffed by humans (config files, fixtures); leave indent=None (the default) for compact machine-to-machine payloads.
  • Wrap json.loads()/json.load() in a try/except json.JSONDecodeError block whenever the JSON comes from an untrusted or external source, rather than assuming it’s always well-formed.
  • Prefer the default parameter or a custom json.JSONEncoder subclass over manually pre-converting your whole object graph into plain dicts before serializing.
  • Set ensure_ascii=False when 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=True when 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 ijson library instead of a single json.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 matching load_config(path) function that reads it back. Test them by round-tripping a small settings dictionary.
  • You have a list of Python set objects that you need to store as JSON. Since JSON has no set type, write a default function that converts any set into a sorted list, and use it with json.dumps().
  • Given the JSON string '{"user": {"name": "Sam", "orders": [{"id": 1, "total": 42.5}, {"id": 2, "total": 17.0}]}}', parse it with json.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 json module maps them onto dict, list, str, int/float, bool, and None automatically 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 default parameter (and object_hook on 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 a try/except json.JSONDecodeError.
  • Use indent, sort_keys, and ensure_ascii to control the readability, diff-friendliness, and Unicode handling of your output.