Decoding into your own objects

json · Practical · 10 min

Turning raw dicts into typed objects at the boundary keeps the rest of the code clean.

json.loads accepts hooks that run while parsing:

  • object_hook=fn is called with every decoded JSON object (as a dict), innermost first. Whatever it returns replaces that dict. Use it to turn tagged dicts into objects, e.g. {"__type__": "point", "x": 1, "y": 2} into a Point.
  • object_pairs_hook=fn receives the object's contents as a list of (key, value) pairs instead of a dict. It sees duplicate keys, which a dict would silently collapse (the last one wins), so it's the way to reject them. If both hooks are given, this one takes priority.
  • parse_float=fn is called with the string of every JSON float. parse_float=Decimal keeps exact values like "19.90" for money.
  • parse_int=fn does the same for integers.
  • parse_constant handles NaN, Infinity and -Infinity; raise there to reject them.

Hooks run on every nested object, so keep them fast and make them return the dict unchanged when it isn't the kind they convert.

For anything beyond a couple of types, converting after decoding is often clearer: parse to dicts, then construct your objects with explicit code or a validation library.

Example

import json
from decimal import Decimal

def no_duplicates(pairs):
    keys = [k for k, _ in pairs]
    dupes = {k for k in keys if keys.count(k) > 1}
    if dupes:
        raise ValueError(f"duplicate keys: {sorted(dupes)}")
    return dict(pairs)

text = '{"price": 19.90, "qty": 3}'
print(json.loads(text))                        # {'price': 19.9, 'qty': 3}
print(json.loads(text, parse_float=Decimal))   # {'price': Decimal('19.90'), 'qty': 3}

print(json.loads('{"a": 1, "a": 2}'))          # {'a': 2}
try:
    json.loads('{"a": 1, "a": 2}', object_pairs_hook=no_duplicates)
except ValueError as e:
    print(e)                                   # duplicate keys: ['a']

point = lambda d: (d["x"], d["y"]) if d.keys() == {"x", "y"} else d
print(json.loads('{"p": {"x": 1, "y": 2}}', object_hook=point))  # {'p': (1, 2)}

Remember

  • object_hook transforms every decoded object, innermost first
  • object_pairs_hook sees raw key/value pairs, including duplicates
  • parse_float=Decimal keeps exact decimal values

Watch out for

  • Forgetting that hooks run for every nested object, so returning None for 'other' dicts wipes out data.

Check yourself

  1. What does json.loads('{"k": 1, "k": 2}') return by default?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.python.org/3/library/json.html#json.loads