cattrs
cattrs turns unstructured data (the dicts and lists you get back from json.loads, YAML, msgpack, or a database driver) into typed Python objects, and turns those objects back into plain dicts. It reads the type annotations on your attrs classes, dataclasses, TypedDicts, and NamedTuples, then generates a specialized conversion function per class and caches it, so the per-object cost stays low even on big payloads. The design choice that defines the library: conversion rules live on a Converter object, never on your model. Your classes stay free of serialization details, and one class can have two different wire shapes if two parts of your system need that. It comes from the attrs maintainers and ships preconfigured converters for orjson, ujson, msgpack, cbor2, bson, PyYAML, tomlkit, tomllib, and msgspec.
If your classes are already attrs or dataclasses, cattrs is the cleanest way to move them to and from JSON without turning your domain model into a serialization schema. Reach for pydantic instead when you are starting fresh and want models and validation in the same object.
Use it if
- You already have attrs classes or dataclasses and need to load JSON or YAML into them without rewriting the whole model layer as pydantic models
- You need two wire shapes for one class (camelCase for a public API, snake_case for internal storage) which works because hooks live on the converter, not on the class
- You must structure into types you do not own: stdlib types, enums, Literal, unions, PEP 695 type aliases, and third-party classes all get hooks without touching their source
- Throughput matters and you want the orjson or msgspec preconf converter, which pairs generated per-class hooks with a fast serializer instead of a Python-level walk
- Your job is validating untrusted input and the models do not exist yet: pydantic gives you constraint validation, JSON Schema generation, and settings handling in one package, while cattrs checks types during structuring and deliberately leaves value rules to attrs validators
- Your unions are not tagged and members do not each have a unique required field: automatic disambiguation gives up, and you have to write a disambiguation function or apply configure_tagged_union yourself before anything works
- You want a shallow learning curve: hooks, hook factories, strategies, make_dict_structure_fn, and override() are four separate concepts you need before you can bend a nonstandard payload into shape, and guessing does not work
- It is a small internal script: attrs.asdict plus a hand-written from_dict is about twenty lines, zero new concepts, and one fewer dependency to keep current
- You are stuck on Python 3.9 or older; 26.1.0 dropped it and you would be pinning an old release forever
Setup reality
pip install cattrs pulls attrs>=25.4.0 and typing-extensions, is pure Python, and needs 3.10 or newer. Nothing compiles, so the friction is all conceptual. The module-level structure() and unstructure() functions run on a process-global converter; the first time you register a hook you should stop using them and build your own Converter, or your hook becomes global state that another import can see. Converter versus BaseConverter matters too: Converter (also exported as GenConverter) generates code and is the one you want, while BaseConverter walks types at runtime and treats tuples and sets differently. The preconf converters are extras, so pip install 'cattrs[orjson]' or 'cattrs[msgpack]' if you want them, and if you skip that you will be writing datetime and bytes hooks by hand. Structuring errors arrive as ExceptionGroups, so print cattrs.transform_error(exc) or the traceback is close to unreadable.
Patterns
Convert a dict into a class and backstructure-and-unstructure
from attrs import define
from cattrs import structure, unstructure
@define
class User:
id: int
tags: list[str]
u = structure({"id": 1, "tags": ["a", "b"]}, User)
print(u) # User(id=1, tags=['a', 'b'])
print(unstructure(u)) # {'id': 1, 'tags': ['a', 'b']}These module-level functions run on cattrs.global_converter. Fine for a quick script, but any hook you register here is visible process-wide, so switch to your own Converter as soon as you customize anything.
Make your own converter instead of the global oneown-converter
from cattrs import Converter
converter = Converter(forbid_extra_keys=True, omit_if_default=True)
# forbid_extra_keys: unknown dict keys raise ForbiddenExtraKeysError
# omit_if_default: fields equal to their default are left out of outputConverter is the codegen converter (also exported as GenConverter). BaseConverter is the slower reflective one and differs on tuples and sets, so do not swap them casually.
Teach a converter about datetimedatetime-hooks
from datetime import datetime, timezone
from cattrs import Converter
converter = Converter()
converter.register_unstructure_hook(datetime, lambda dt: dt.isoformat())
converter.register_structure_hook(
datetime, lambda v, _: datetime.fromisoformat(v)
)
converter.structure("2026-08-06T10:00:00+00:00", datetime)Structure hooks always take two arguments (value, type); unstructure hooks take one. Plain Converter has no datetime support at all, which surprises people. The preconf converters register these for you.
Rename fields for a camelCase APIrename-fields
from attrs import define
from cattrs import Converter, override
from cattrs.gen import make_dict_structure_fn, make_dict_unstructure_fn
@define
class Account:
account_id: int
display_name: str
c = Converter()
renames = {
"account_id": override(rename="accountId"),
"display_name": override(rename="displayName"),
}
c.register_unstructure_hook(Account, make_dict_unstructure_fn(Account, c, **renames))
c.register_structure_hook(Account, make_dict_structure_fn(Account, c, **renames))
c.unstructure(Account(1, "Ann")) # {'accountId': 1, 'displayName': 'Ann'}You must register both directions; renaming one way silently breaks the round trip. Because the rename lives on this converter, a second converter can keep snake_case for the same class.
Serialize straight to JSON bytes with orjsonpreconf-orjson
# pip install 'cattrs[orjson]'
from attrs import define
from cattrs.preconf.orjson import make_converter
@define
class Event:
name: str
c = make_converter()
raw = c.dumps(Event("deploy")) # b'{"name":"deploy"}'
print(c.loads(raw, Event))Preconf converters add dumps/loads and pre-register hooks for datetime, date, bytes, and sets in the shape that serializer expects. cattrs.preconf.json uses the stdlib and needs no extra install.
Structure a union by a discriminator fieldtagged-union
from attrs import define
from cattrs import Converter
from cattrs.strategies import configure_tagged_union
@define
class Cat:
lives: int
@define
class Dog:
breed: str
c = Converter()
configure_tagged_union(Cat | Dog, c, tag_name="kind")
c.structure({"kind": "Cat", "lives": 9}, Cat | Dog)The default tag value is the class name; pass tag_generator to control it. Without this strategy cattrs tries to disambiguate on unique required fields and raises if two members look alike.
Turn a validation ExceptionGroup into readable linesreadable-errors
from attrs import define
from cattrs import Converter, transform_error
from cattrs.errors import ClassValidationError
@define
class Point:
x: int
y: int
c = Converter()
try:
c.structure({"x": "nope"}, Point)
except ClassValidationError as exc:
for line in transform_error(exc):
print(line)With detailed_validation on (the default) cattrs collects every field error into an ExceptionGroup instead of stopping at the first. transform_error flattens it into strings with a path like '@ $.x'.
Drop a field from output entirelyomit-and-rename-one-field
from attrs import define, field
from cattrs import Converter, override
from cattrs.gen import make_dict_unstructure_fn
@define
class Session:
user: str
token: str = field(default="")
c = Converter()
c.register_unstructure_hook(
Session, make_dict_unstructure_fn(Session, c, token=override(omit=True))
)
c.unstructure(Session("ann", "secret")) # {'user': 'ann'}omit=True only affects the direction you registered. If the incoming payload still carries the key and your converter has forbid_extra_keys=True, structuring will now fail.
Apply one rule to every attrs class at oncehook-factory-all-classes
from attrs import has
from cattrs import Converter
from cattrs.gen import make_dict_unstructure_fn
c = Converter()
c.register_unstructure_hook_factory(
has,
lambda cls, conv: make_dict_unstructure_fn(
cls, conv, _cattrs_omit_if_default=True
),
)A hook factory runs once per class the first time that class is seen, then the generated hook is cached. attrs.has is the predicate for 'is this an attrs class'; use dataclasses.is_dataclass for dataclasses.
Structure a base class into the right subclassinclude-subclasses
from attrs import define
from cattrs import Converter
from cattrs.strategies import include_subclasses
@define
class Shape:
name: str
@define
class Circle(Shape):
radius: float
c = Converter()
include_subclasses(Shape, c)
c.structure({"name": "c", "radius": 1.0}, Shape) # Circle(...)Subclasses must already be imported when you call include_subclasses, since it walks __subclasses__ at that moment. Combine it with configure_tagged_union when field sets overlap.
Use it without attrs at alldataclasses-and-typeddict
from dataclasses import dataclass
from typing import TypedDict
from cattrs import Converter
@dataclass
class Item:
sku: str
qty: int
class Row(TypedDict):
sku: str
qty: int
c = Converter()
c.structure({"sku": "a", "qty": 2}, Item)
c.structure({"sku": "a", "qty": 2}, Row)Dataclasses, TypedDicts, and NamedTuples work through the same machinery as attrs classes, so you do not have to adopt attrs to use cattrs.
Trade error detail for speed on trusted inputdisable-detailed-validation
from cattrs import Converter
fast = Converter(detailed_validation=False)
# raises the first underlying error (ValueError, KeyError, ...)
# instead of collecting an ExceptionGroupOnly worth it on data you already trust, such as your own cache or internal queue. On external payloads you lose the field path and end up debugging a bare ValueError.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| pydantic | PyPI | You are defining models from scratch and want validation, JSON Schema, and serialization in one library that every Python dev already recognizes. |
| msgspec | PyPI | You control the class definitions, need the fastest JSON or MessagePack path available, and accept its own Struct base class and narrower type coverage. |
| dacite | PyPI | You only need dict to dataclass in one direction and want a tiny library with almost no configuration surface to learn. |
| marshmallow | PyPI | You want explicit schema objects kept separate from your classes, with per-field validation and error dictionaries suited to form and API handlers. |