mrkeyoor.com_
Thu 06 Aug 08:52 UTC
PyPIUtilsupdated 06 Aug 2026

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.

Verdict

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.

API stability4/5The core Converter API has held its shape for years under a documented backwards-compatibility policy, but CalVer releases do move things: 25.3.0 changed abstract sets to structure into frozensets and 26.1.0 dropped Python 3.9, both flagged in a dedicated migrations page.
Docs4/5catt.rs is split into sensible pages (customizing, strategies, preconf, migrations, a Why cattrs essay) and nearly every feature has a runnable example; the hook factory and generation-function pages stay terse enough that you often end up reading source.
Maintenance4/5Pushed August 2026 with 78 open issues (88 counting PRs), a CalVer release each year since 2022, and a changelog that credits every fix to a PR; it lives in the python-attrs org but is effectively driven by one maintainer, Tin Tvrtkovic.
Ecosystem3/5Around 17.2 million weekly PyPI downloads against only 1,048 GitHub stars, because most of that traffic is transitive (requests-cache pins cattrs>=22.2, for example); preconf adapters cover nine serializers, but there is no third-party plugin scene like pydantic's.

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
Skip it if

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 output

Converter 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 ExceptionGroup

Only 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

PackageRegistryPick it when
pydanticPyPIYou are defining models from scratch and want validation, JSON Schema, and serialization in one library that every Python dev already recognizes.
msgspecPyPIYou control the class definitions, need the fastest JSON or MessagePack path available, and accept its own Struct base class and narrower type coverage.
dacitePyPIYou only need dict to dataclass in one direction and want a tiny library with almost no configuration surface to learn.
marshmallowPyPIYou want explicit schema objects kept separate from your classes, with per-field validation and error dictionaries suited to form and API handlers.