Practical projects · about 45 min

Working with JSON

Why this matters

JSON (JavaScript Object Notation) is how most programs exchange structured data today: web APIs return it, configuration files use it, logs are written in it one object per line, and tools like jq slice it on the command line. Python's built-in json module converts between JSON text and ordinary Python dicts and lists in one call. The skills are converting correctly, navigating nested data, writing output that humans and diff tools can read, and failing clearly when the input is broken.

The JSON data model

JSON is a text format with six kinds of values, and each maps onto a Python type:

JSONExamplePython
object{"name": "Ada", "age": 36}dict (keys are always str)
array[1, 2, 3]list
string"hello" (double quotes only)str
number42, 3.14, -1e3int or float
true / falsetrueTrue / False
nullnullNone

When encoding, Python also accepts tuples (written as arrays) and int, float or bool dict keys (converted to strings). Anything else, such as sets, datetime objects, your own classes or bytes, raises TypeError: Object of type set is not JSON serializable.

JSON is stricter than Python syntax: strings and keys need double quotes, there are no comments, no trailing commas, and True must be true. A JSON document is valid Python surprisingly often, which tempts people to use eval on it. Never do that: eval executes code, and JSON from the outside is untrusted input.

Four functions

import json

text = '{"name": "Ada", "langs": ["python", "c"], "admin": false, "manager": null}'
data = json.loads(text)          # load from a string
print(data)
print(data["langs"][0], type(data["admin"]))

back = json.dumps(data)          # dump to a string
print(back)
{'name': 'Ada', 'langs': ['python', 'c'], 'admin': False, 'manager': None}
python <class 'bool'>
{"name": "Ada", "langs": ["python", "c"], "admin": false, "manager": null}

The s stands for string. The versions without s work with file objects:

with open("config.json", encoding="utf-8") as f:
    config = json.load(f)        # read and parse the whole file

with open("out.json", "w", encoding="utf-8") as f:
    json.dump(config, f, indent=2)

json.load(sys.stdin) reads JSON piped into your program, which makes your script work well with curl ... | python3 tool.py.

Converting Python objects to text is called serialization (or encoding); parsing text back is deserialization (decoding).

Readable, stable output

By default dumps writes everything on one line. Two options make output friendly to humans and to version control:

data = {"zeta": 1, "alpha": [1, 2], "mid": {"b": 2, "a": 1}}
print(json.dumps(data, indent=2, sort_keys=True))
{
  "alpha": [
    1,
    2
  ],
  "mid": {
    "a": 1,
    "b": 2
  },
  "zeta": 1
}

indent=2 pretty-prints; sort_keys=True orders keys alphabetically, so the same data always produces byte-identical output, which keeps diffs small and tests stable. Other useful options: ensure_ascii=False writes non-ASCII characters as themselves ("café") instead of escapes ("café"), and separators=(",", ":") produces the most compact form. json.dump does not add a final newline; write "\n" yourself if you want the file to end cleanly.

Real JSON is nested: objects inside arrays inside objects. Work through it one level at a time, and know what type you hold at each step:

import json

doc = json.loads("""
{"repo": "learnprog",
 "commits": [
   {"sha": "d2d5a3a", "author": {"name": "grace"}, "files": ["cli.py", "checker.py"]},
   {"sha": "9f1c0b2", "author": {"name": "ada"}, "files": []}
 ]}
""")

for commit in doc["commits"]:               # a list of dicts
    who = commit["author"]["name"]          # dict inside dict
    print(commit["sha"], who, len(commit["files"]))
d2d5a3a grace 2
9f1c0b2 ada 0

For optional fields, use .get with a default instead of [...], which raises KeyError:

email = user.get("email") or "-"     # missing key and null both become "-"

Note the difference: user.get("email", "-") returns None if the key exists with value null; or "-" covers both cases (and also the empty string, which may or may not be what you want).

When the structure gets deep, converting to dataclasses at the boundary of your program makes the rest of the code clearer and type-checked:

from dataclasses import dataclass

@dataclass
class Commit:
    sha: str
    author: str
    files: list[str]

commits = [Commit(c["sha"], c["author"]["name"], c["files"]) for c in doc["commits"]]

Serializing your own types

To encode objects JSON does not know, pass a default function that converts them into something it does know:

import json
from datetime import date

def encode(obj):
    if isinstance(obj, date):
        return obj.isoformat()
    if isinstance(obj, set):
        return sorted(obj)
    raise TypeError(f"cannot serialize {type(obj).__name__}")

print(json.dumps({"day": date(2026, 11, 1), "tags": {"b", "a"}}, default=encode))
{"day": "2026-11-01", "tags": ["a", "b"]}

For a dataclass, dataclasses.asdict(obj) gives a dict ready for dumps. Decoding back into rich types is your job: JSON only knows the six basic kinds, so a date comes back as a string until you call date.fromisoformat.

Handling bad input

Invalid JSON raises json.JSONDecodeError, a subclass of ValueError that knows where the problem is:

import json

try:
    json.loads('{"port": 8080,}')
except json.JSONDecodeError as e:
    print(e)
    print(e.lineno, e.colno, e.pos)
Illegal trailing comma before end of object: line 1 column 14 (char 13)
1 14 13

Valid JSON may still be the wrong shape: an array where you expected an object, a string where you expected a number. Check types at the boundary with isinstance and report a clear error, rather than letting a confusing TypeError or KeyError surface deep in your code. One subtlety: bool is a subclass of int in Python, so isinstance(True, int) is True; exclude bools explicitly when a number is required.

JSON Lines

Logs and data exports often use JSON Lines (.jsonl): one complete JSON value per line. It can be appended to and processed as a stream, without loading everything into memory:

import json

with open("events.jsonl", encoding="utf-8") as f:
    for lineno, line in enumerate(f, 1):
        if line.strip():
            event = json.loads(line)
            ...

Common mistakes

Recap

Going further: try python3 -m json.tool file.json, a built-in pretty-printer and validator, and jq '.users[] | .name' in the sandbox to query JSON from the shell.

Exercises

Exercise 1 · quiz

A lossy round trip

What does this print?

import json
data = {1: (2, 3), "ok": True, "none": None}
print(json.loads(json.dumps(data)))
Answer
  • {'1': [2, 3], 'ok': True, 'none': None}

JSON object keys are always strings, so dumps converts the int key 1 to "1". JSON has arrays but no tuples, so the tuple becomes an array and comes back as a list. True and None map to JSON true and null and back again. JSON round trips preserve the data, not the exact Python types.

Exercise 2 · write code

Filter users from JSON on stdin

Read a JSON document from standard input (use json.load(sys.stdin)). It is an object with a "users" array; each user has "name" (string), "age" (number) and "active" (boolean), and may have an "email" (string or null) or no email key at all.

Print the active users aged 18 or over, sorted by name, one per line as name <email>, using - when the email is missing or null. Then print a final line count: N. Example output:

Ada <ada@example.com>
Linus <->
count: 2

File: main.py

Starting point

import json
import sys

data = json.load(sys.stdin)
print(data)

Example

input

{"users": [
  {"name": "Linus", "age": 30, "active": true},
  {"name": "Bob", "age": 17, "active": true, "email": "bob@example.com"},
  {"name": "Ada", "age": 36, "active": true, "email": "ada@example.com"},
  {"name": "Carol", "age": 50, "active": false, "email": "carol@example.com"}
]}

expected output

Ada <ada@example.com>
Linus <->
count: 2

Example

input

{"users": [{"name": "Zed", "age": 18, "active": true, "email": null}, {"name": "Amy", "age": 18.5, "active": true, "email": "amy@x.org"}]}

expected output

Amy <amy@x.org>
Zed <->
count: 2
Hint 1

After json.load, data["users"] is a Python list of dicts, and JSON true is Python True.

Hint 2

u.get("email") returns None both when the key is missing and when the value is null; or "-" turns that into a dash.

Hint 3

Sort with key=lambda u: u["name"].

Reference solution
import json
import sys


def main():
    data = json.load(sys.stdin)
    chosen = [u for u in data["users"] if u["active"] and u["age"] >= 18]
    chosen.sort(key=lambda u: u["name"])
    for u in chosen:
        print(f"{u['name']} <{u.get('email') or '-'}>")
    print(f"count: {len(chosen)}")


if __name__ == "__main__":
    main()

Exercise 3 · write code

Summarize nested orders

In solution.py, write summarize(in_path, out_path). The input file holds a JSON array of orders like:

[{"id": 1, "customer": {"name": "Ada"}, "items": [{"sku": "A1", "qty": 2, "price": 3.5}]}]

Compute, per customer name, the number of orders and the total spent (sum of qty * price over all items, rounded to 2 decimals with round(x, 2)). Write to out_path a JSON object mapping each customer name to {"orders": N, "total": T}, using json.dump(..., indent=2, sort_keys=True), and return the same dict.

An order with an empty items list still counts as an order with total 0.

File: solution.py

Starting point

import json


def summarize(in_path, out_path):
    return {}
Hint 1

Navigate nested data step by step: order["customer"]["name"], then loop over order["items"].

Hint 2

summary.setdefault(name, {"orders": 0, "total": 0.0}) returns the existing entry or inserts a fresh one.

Hint 3

Round at the end, after summing, to avoid accumulating rounding differences.

Reference solution
import json


def summarize(in_path, out_path):
    with open(in_path, encoding="utf-8") as f:
        orders = json.load(f)
    summary = {}
    for order in orders:
        name = order["customer"]["name"]
        entry = summary.setdefault(name, {"orders": 0, "total": 0.0})
        entry["orders"] += 1
        entry["total"] += sum(item["qty"] * item["price"] for item in order["items"])
    for entry in summary.values():
        entry["total"] = round(entry["total"], 2)
    with open(out_path, "w", encoding="utf-8") as f:
        json.dump(summary, f, indent=2, sort_keys=True)
        f.write("\n")
    return summary

Exercise 4 · write code

Fail gracefully on bad JSON

main.py is given a config file path as its only argument. The file should contain a JSON object with a "port" key holding an integer.

Nothing may be printed to stdout on errors.

File: main.py

Starting point

import json
import sys

with open(sys.argv[1]) as f:
    config = json.load(f)
print("port:", config["port"])

Example with arguments good.json

expected output

port: 8080

Example with arguments missing.json

expected output

(nothing)
Hint 1

Catch FileNotFoundError and json.JSONDecodeError separately around the open/load.

Hint 2

print(..., file=sys.stderr) writes to stderr; sys.exit(1) (or returning 1 from main into sys.exit) sets the status.

Hint 3

Check isinstance(config, dict), then isinstance(port, int) and not isinstance(port, bool).

Reference solution
import json
import sys


def main(argv):
    path = argv[1]
    try:
        with open(path, encoding="utf-8") as f:
            config = json.load(f)
    except FileNotFoundError:
        print(f"error: {path} not found", file=sys.stderr)
        return 1
    except json.JSONDecodeError as e:
        print(f"error: {path} is not valid JSON (line {e.lineno}, column {e.colno})", file=sys.stderr)
        return 1
    port = config.get("port") if isinstance(config, dict) else None
    if not isinstance(port, int) or isinstance(port, bool):
        print(f'error: {path} needs an integer "port"', file=sys.stderr)
        return 1
    print(f"port: {port}")
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv))