We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← Modelling Data with Dataclasses step 20 of 28
Build a JSON Codec for Dataclasses
Your service receives account records as JSON, from requests and from
files that an older version of the app saved. The rest of the code should
only ever see a checked Account, and it must be able to save an account
as JSON again. Here you write a codec, an object that converts a
dataclass to JSON-ready values and back. In Parse Untrusted JSON into a
Dataclass you checked each field by name. A codec reads each field's type
from the class instead.
raw = {
"id": "00000000-0000-4000-8000-000000000001",
"created_at": "2026-01-02T03:04:05+00:00",
"balance": "10.50",
"tier": "pro",
"labels": ["a", "b"],
"address": {"city": "Lisbon"},
}
solve(raw, 2)
# {"error": "missing:address.zip_code", "dump": None, "roundtrip": False}
Keep the supplied Account, Address and Tier as they are, and do not
write their field names into the codec. Input keys are always strings, and
version is 1 or 2.
Write dump
Codec(cls).dump(obj) converts each value like this:
| Value | Result |
|---|---|
| Dataclass instance | A new dict with one key per field, in field order, each value converted |
datetime |
value.isoformat() |
Decimal or UUID |
str(value) |
| Enum member | Its value, converted |
| Tuple | A new list of the converted items |
None, bool, int, str |
The value itself |
Raise CodecError("", "unsupported_type") for any other value. Build the
dicts and lists yourself, without asdict or deepcopy.
Write load
Codec(cls).load(raw) checks raw and builds a cls from it. Decode
each value by its field's type, f.type:
strneeds a string.intneeds an int and rejectsTrueandFalse, which Python counts as ints.UUID,datetimeandDecimalneed a string. Convert it withUUID(text),datetime.fromisoformat(text)orDecimal(text). ADecimalthat is not finite, such as"NaN", counts as malformed.- An enum needs a string.
Tier("pro")finds the member with that value. tuple[X, ...]needs a list. Decode each item asX.X | NonekeepsNoneand decodes any other value asX.- A dataclass needs a mapping. Load its fields the same way.
- Any other type is
unsupported_type.
Check each object, the root and the address, in this order, and stop at the first error:
- The object must be a mapping, or the error is
expected_object. - A key that is not a field is
unknown_key, with the key at the end of the path. If there are several, report the one that sorts first. - Decode the fields in declaration order. Leave out a missing field that
has a default or a default factory, so the constructor fills it in. A
missing field with neither is
missing.
Report where the input is wrong
Raise CodecError(path, problem). Its str() is "<problem>:<path>".
The path joins field names with dots and puts list positions in brackets,
as in address.zip_code and labels[1]. The root path is empty, so a
list at the root gives expected_object:.
| Situation | problem |
|---|---|
| A key that is not a field | unknown_key |
| A required field is missing | missing |
| The value has the wrong JSON type | expected_str, expected_int, expected_list or expected_object |
| Bad UUID, datetime or Decimal text, or a Decimal that is not finite | malformed |
| A string that is not one of the enum's values | unknown_enum |
| A type the codec does not support | unsupported_type |
So a tier of 123 gives expected_str:tier, and "platinum" gives
unknown_enum:tier. Around each conversion, catch only ValueError and
decimal.InvalidOperation, which Decimal("ten") raises. A wider
except would report a bug in your own code as bad input.
Upgrade version 1 records
upgrade_v1(raw) returns a new dict and leaves raw and its address
unchanged:
- Rename
createdtocreated_at. - If
addressis a mapping, copy it and rename itszipkey tozip_code. - If both the old name and the new one are present, keep the old name's value.
- Add
"tier": "free"only whentieris missing. Leave aNoneor an invalid tier forloadto reject. - Keep every other key, so
loadcan reject the unknown ones.
What solve returns
solve(raw, version) upgrades a version 1 record, loads the Account,
dumps it, and loads the dump again.
- On success it returns
{"error": "", "dump": dumped, "roundtrip": loaded_again == account}. - On a
CodecErrorit returns{"error": str(error), "dump": None, "roundtrip": False}. - For version 1, a
rawthat is not a mapping givesexpected_object:before any upgrade.
roundtrip compares the loaded objects, not the JSON text. A timestamp
that ends in Z dumps with +00:00, and the objects are still equal.
Type checking
f.typeholds the real type for these classes, so read it directly. The exercise Resolve Dataclass Field Types in Python 3.14 covers fields where it does not.DataclassInstanceis a type that exists only for the type checker and means "any dataclass instance". That is why the starter imports it underif TYPE_CHECKING:and writes it in quotes.- mypy knows that
Tis some dataclass, but not which arguments its constructor takes. Writemaker: Callable[..., T] = self._cls, then callmaker(**kwargs). The annotation says the class takes any arguments and returns aT.
Your code, saved here
Sign in to access your accepted solutions and saved draft history. You can keep reading the lesson without an account.
Sign in to your accountStuck?
Python reference solution
Sign in to track your attempts toward unlocking the reference solution.