Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Serialisation & Storage  ›  Lesson

JSON, and the Types It Cannot Hold

JSON 22 minCrossing between Python types and JSON types on purpose, not by accident
You're building a piece ofQuarry — the vault
This piece — to_json(): Turns a typed record into stable JSON, saying out loud what JSON cannot hold.
Scenario The record stage hands the vault a batch of parsed incidents with real timestamps, tag sets and exact sizes. The downstream dashboard only speaks JSON.
Your task
Write encode_extra(value), which turns a datetime into .isoformat(), a set into a sorted list and a Decimal into str and raises TypeError for anything else, and to_json(raw), which returns json.dumps(build_record(raw), default=encode_extra, sort_keys=True, separators=(",", ":"), ensure_ascii=False). Example: tags ["web", "disk"] come out as ["disk","web"] and size "12.50" stays "12.50".

JSON, and the Types It Cannot Hold

In short: json.dumps(obj) turns a Python object into text and json.loads(text) turns it back; dump/load do the same to a file. JSON has only six kinds of value, and Python has dozens, so every round trip is a border crossing: str, int, float, bool, None, list and dict cross unchanged, a tuple crosses and comes back a list, an int dict key comes back a str key — both silently, nothing raises — and a datetime, set, Decimal or bytes is refused outright with a TypeError, unless you hand dumps a default= function that says how to convert it. The gap is real for Quarry: timestamps are datetimes, tags are sets, and a byte count that must stay exact is a Decimal.

You'll build to_json(raw), Quarry's encoder, which crosses that gap on purpose instead of by accident.

Python types crossing a bridge into JSON: plain types cross unchanged, tuple and int keys cross but change shape, datetime/set/Decimal are refused with a TypeError unless you write a default= hook

Ship a log record to a JSON dashboard

A dashboard that only speaks JSON needs Quarry's record turned into JSON text. The straightforward call works for plain values:

python

False became false, None became null, and single quotes became double — the only kind JSON allows. The trouble starts with the types JSON has no room for. A datetime is refused on the spot — the good kind of failure, loud and early:

python

default= is the fix: dumps calls your function on anything it can't encode and writes whatever it returns instead:

python

Three choices there are decisions, not formalities. sorted(value), not list(value), because a set's iteration order isn't stable and the same data must always produce the same text. str(value), not float(value), for Decimal — Decimal("0.10") exists so it is not the binary approximation of 0.1. And the hook raises at the end rather than falling back to str(value) for everything, which would quietly turn an open file into "<_io.TextIOWrapper ...>" in your data.

Fix this logger, which crashes on its first record:

broken — fix itTypeError

Run it and read which type JSON refuses. Then teach dumps what to do with it.

Compare today's export with yesterday's

Quarry fingerprints batches, so the same data must always produce the same text. Two dicts built in different field order are still "the same data":

python

sort_keys=True removes insertion order from the output. separators=(",", ":") drops the spaces, which also removes one more way two encoders could disagree. The values that get through and shouldn't are quieter than a refusal:

What does this print?
import json

print(json.dumps({"ratio": float("nan")}))

json.dumps(obj, allow_nan=False) raises ValueError instead, catching the bad value where it was made rather than in someone else's system later.

Bring timestamps back to life on the way in

default= works on the way out; object_hook= is its partner coming in. loads calls it on every decoded dict, innermost first, and uses whatever it returns:

python

The hook relies on a naming convention, the _at suffix, because JSON itself carries no type information. Watch the hook fire once per value it handles, never for the ones it doesn't need to:

step through it
1import json
2from datetime import datetime
3 
4calls = []
5 
6def encode_extra(value):
7 calls.append(type(value).__name__)
8 if isinstance(value, datetime):
9 return value.isoformat()
10 return sorted(value)
11 
12record = {"id": 1, "at": datetime(2026, 3, 1), "tags": {"b", "a"}}
13text = json.dumps(record, default=encode_extra, sort_keys=True)
14print(text, calls)
Drawing diagram…

Quick practice

What does this print?
import json

hits = {404: 3, 500: 1}
back = json.loads(json.dumps(hits))
print(list(back))
What does this print?
import json

span = (3, 9)
back = json.loads(json.dumps(span))
print(back == span, type(back).__name__)
broken — fix itTypeError

Run it, read which type is refused, then add a default= hook that turns a set into a sorted list.

What does this print?
import json

escaped = json.dumps({"city": "Zürich"})
plain = json.dumps({"city": "Zürich"}, ensure_ascii=False)
print(escaped == plain, "u" in escaped, "u" in plain)

🎯 Your turn

Quarry's record stage hands the vault typed records. The starter includes build_record(raw), a stand-in for that stage: it turns at into a datetime, tags into a set and size into a Decimal. Write:

  • encode_extra(value): datetime → .isoformat(), set → sorted list, Decimal → str, and anything else raises TypeError
  • to_json(raw): json.dumps(build_record(raw), default=encode_extra, sort_keys=True, separators=(",", ":"), ensure_ascii=False)

Example:

to_json({"id": 1, "level": "WARN", "at": "2026-03-01T09:30:00",
         "tags": ["web", "disk"], "size": "12.50"})
→ '{"at":"2026-03-01T09:30:00","id":1,"level":"WARN","size":"12.50","tags":["disk","web"]}'

Hint: isinstance(value, (set, frozenset)) catches both kinds of set.

Press ▶ Run, try the Live App chips, and hit ✓ Check. ✅

Practice — level up

0/6 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upJSON for humans

Write pretty(record) returning the record as JSON with keys sorted and two-space indentation.

jsonjson-dumps
DrillRead a JSON Lines dump

Log exporters often write JSON Lines: one JSON object per line. Write decode_lines(text) returning the list of decoded objects, skipping blank or whitespace-only lines.

jsonjson-loads
BuildAn object_hook that revives dates

Write revive_types(text). Decode text with an object_hook that turns the value of any key ending in _at into a datetime using datetime.fromisoformat, leaving it as a string if that raises ValueError. Return a dict mapping each top-level key to the type name of its decoded value, e.g. {"id": "int", "seen_at": "datetime"}.

jsonobject-hook
BossWhich fields did JSON change?

The starter's typed(raw) turns pair into a tuple, counts into a dict with int keys, tags into a set and at into a datetime. Write roundtrip_diff(raw): build record = typed(raw), encode it with a default= hook (sets to sorted lists, datetimes to ISO strings), decode it again, and return the sorted list of keys whose decoded value is not equal to the original.

jsonjson-default
CapstoneEncode a batch, and report what wouldn't go

Write to_jsonl(raws). For each raw record, build it with the starter's build_record and encode it on one line exactly as the lesson's to_json does (sorted keys, compact separators, ensure_ascii=False, and default= for datetime, set and Decimal). If building a record raises ValueError (from a bad date) or decimal.InvalidOperation (from a bad size), skip it and remember its id. Return {"text": the encoded lines joined with "\n", "skipped": [ids in input order]}.

jsonjson-defaultexceptions
ExplainWhy `allow_nan=False` belongs in Quarry's encoder

json.dumps({"ratio": float("nan")}) succeeds and prints {"ratio": NaN}, and json.loads reads it back without complaint. Explain why this is still a bug, who it hurts, and what allow_nan=False changes about where the problem shows up.

to_json.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
to_json(raw) → strBuild the typed record, then encode it as stable, compact JSON.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.