Skip to content

← Modelling Data with Dataclasses step 20 of 28

Hard End-to-End

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:

  • str needs a string. int needs an int and rejects True and False, which Python counts as ints.
  • UUID, datetime and Decimal need a string. Convert it with UUID(text), datetime.fromisoformat(text) or Decimal(text). A Decimal that 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 as X.
  • X | None keeps None and decodes any other value as X.
  • 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:

  1. The object must be a mapping, or the error is expected_object.
  2. 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.
  3. 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 created to created_at.
  • If address is a mapping, copy it and rename its zip key to zip_code.
  • If both the old name and the new one are present, keep the old name's value.
  • Add "tier": "free" only when tier is missing. Leave a None or an invalid tier for load to reject.
  • Keep every other key, so load can 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 CodecError it returns {"error": str(error), "dump": None, "roundtrip": False}.
  • For version 1, a raw that is not a mapping gives expected_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.type holds 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.
  • DataclassInstance is a type that exists only for the type checker and means "any dataclass instance". That is why the starter imports it under if TYPE_CHECKING: and writes it in quotes.
  • mypy knows that T is some dataclass, but not which arguments its constructor takes. Write maker: Callable[..., T] = self._cls, then call maker(**kwargs). The annotation says the class takes any arguments and returns a T.