To turn JSON into objects of a custom class, we pass a function as object_hook to json.loads() or json.load(); the json module calls it for every JSON object it parses, and whatever the function returns replaces the plain dictionary. That is how an API response or a saved file becomes a list of typed objects instead of nested dictionaries.
import json
from dataclasses import dataclass
from datetime import date
@dataclass
class Booking:
guest: str
nights: int
check_in: date
def to_booking(d):
return Booking(d["guest"], d["nights"], date.fromisoformat(d["check_in"]))
text = '{"guest": "Lokesh", "nights": 3, "check_in": "2026-11-02"}'
booking = json.loads(text, object_hook=to_booking)
# Booking(guest='Lokesh', nights=3, check_in=datetime.date(2026, 11, 2))
This article is the reverse of custom class serialization and uses the same Booking class. We cover object_hook with nested data, the **d shortcut, a JSONDecoder subclass, object_pairs_hook and validation, on Python 3.14.
1. How object_hook Works
The json module parses the text from the inside out. Each time it finishes a JSON object, it calls the hook with that object as a dict, so nested objects reach the hook before the objects that contain them.
def show(d):
print("hook got:", d)
return d
data = json.loads('{"guest": "Ana", "room": {"no": 12}}', object_hook=show)
hook got: {'no': 12}
hook got: {'guest': 'Ana', 'room': {'no': 12}}
Because the hook sees every object, it must check what kind of dictionary it got and return the others unchanged. A hook that assumes every dictionary is a Booking fails on the first nested object.
2. Decoding a List with Nested Objects
A common shape is a list of records inside a wrapper object. This hook recognizes bookings by their keys and leaves the wrapper as a dictionary.
def hook(d):
if {"guest", "nights", "check_in"} <= d.keys():
return Booking(d["guest"], d["nights"], date.fromisoformat(d["check_in"]))
return d
payload = """{"hotel": "Sea View", "bookings": [
{"guest": "Lokesh", "nights": 3, "check_in": "2026-11-02"},
{"guest": "Ana", "nights": 1, "check_in": "2026-11-05"}]}"""
result = json.loads(payload, object_hook=hook)
first = result["bookings"][0].guest # 'Lokesh'
when = result["bookings"][1].check_in # datetime.date(2026, 11, 5)
Checking the keys is more reliable than adding a type marker such as “__class__” to the JSON, because data from other systems rarely carries Python class names.
3. The Booking(**d) Shortcut
When the JSON keys match the constructor arguments, Booking(**d) builds the object in one call. It stops working as soon as the JSON has an extra key, and it leaves the date as a string.
b = Booking(**{"guest": "Ana", "nights": 1, "check_in": "2026-11-05"})
kind = type(b.check_in) # <class 'str'>, not a date
data = {"guest": "Ana", "nights": 1, "check_in": "x", "vip": True}
extra = Booking(**data) # TypeError: Booking.__init__() got an unexpected keyword argument 'vip'
For real data, an explicit function such as to_booking() picks the fields it needs and converts their types, so new fields in an API response do not break our code.
4. A JSONDecoder Subclass
To reuse the same rules in many places, we wrap the hook in a subclass of json.JSONDecoder and pass it with cls.
class BookingDecoder(json.JSONDecoder):
def __init__(self, **kwargs):
super().__init__(object_hook=hook, **kwargs)
bookings = json.loads(payload, cls=BookingDecoder)["bookings"]
count = len(bookings) # 2
5. object_pairs_hook and Duplicate Keys
By default, a duplicate key in JSON keeps the last value without any warning. The object_pairs_hook receives the key-value pairs as a list, in order, so it can reject duplicates; when both hooks are given, Python uses object_pairs_hook.
def no_duplicates(pairs):
keys = [k for k, _ in pairs]
if len(keys) != len(set(keys)):
raise ValueError(f"duplicate keys in {keys}")
return dict(pairs)
text2 = '{"nights": 1, "nights": 5}'
silent = json.loads(text2) # {'nights': 5}
strict = json.loads(text2, object_pairs_hook=no_duplicates) # ValueError: duplicate keys in ['nights', 'nights']
6. Validating Input Before Building Objects
JSON from a file or an API can miss fields or carry wrong types. Converting inside a try block turns those problems into one clear error with the bad record in the message.
def safe_booking(d):
try:
return Booking(str(d["guest"]), int(d["nights"]), date.fromisoformat(d["check_in"]))
except (KeyError, ValueError, TypeError) as e:
raise ValueError(f"bad booking {d}: {e}") from None
ok = json.loads('{"guest": "Ana", "nights": "2", "check_in": "2026-11-05"}', object_hook=safe_booking)
nights = ok.nights # 2, converted from the string "2"
For large schemas, a validation library such as Pydantic does the same with less code, but the standard library is enough for a handful of fields.
7. Decoding Questions
Lighter containers, decimal prices and third-party libraries are the usual next topics.
7.1. Can we use a namedtuple or SimpleNamespace instead of a class?
Yes. object_hook=lambda d: SimpleNamespace(**d) gives attribute access to every object without writing a class, which suits quick scripts. A namedtuple does the same but needs a fixed set of fields.
7.2. How do we keep decimal values for prices without rounding errors?
We pass parse_float=Decimal, so json.loads(‘{“price”: 9.99}’, parse_float=Decimal) returns Decimal(‘9.99’) instead of a binary float.
7.3. Is jsonpickle a safe alternative?
Only for data we created ourselves. Decoding jsonpickle data can run code from the input, so it must never read JSON from users or other systems.
8. Conclusion
The object_hook function is the standard way to turn JSON objects into our own classes. It runs for every object from the inside out, so it checks the keys before converting, and explicit conversion with date.fromisoformat() and int() keeps the result correct when the input is not.
9. References
Happy Learning !!