Intermediate
When your Python application receives JSON from an API, reads a config file, or loads records from a database, you end up with plain dictionaries. You then write code to manually extract keys, validate types, and convert values into your domain objects. This conversion layer is tedious to write, easy to get wrong, and even easier to forget to update when your data model changes.
cattrs automates this conversion. It converts plain Python structures — dictionaries, lists, and primitives — into typed attrs classes or dataclasses, and back again. It handles nested structures, optional fields, unions, and custom type coercion. When the input doesn’t match the expected type, it raises clear, structured errors that tell you exactly which field failed and why.
This article covers how to install cattrs, how to structure and unstructure objects, how to handle nested and optional types, how to add custom hooks for special conversion logic, and how to use the validation capabilities. By the end you will be able to replace fragile manual conversion code with a clean, reliable structuring layer.
Structuring Data with cattrs: Quick Example
Here is a minimal example converting a dictionary from a JSON API response into a typed Python object:
# quick_cattrs_example.py
import attrs
import cattrs
@attrs.define
class User:
name: str
age: int
email: str
# Simulated API response (as a dict)
raw_data = {"name": "Alice", "age": 30, "email": "alice@example.com"}
# Structure: dict -> typed object
user = cattrs.structure(raw_data, User)
print(user)
print(type(user))
# Unstructure: typed object -> dict (for serialization)
back_to_dict = cattrs.unstructure(user)
print(back_to_dict)
Output:
User(name='Alice', age=30, email='alice@example.com')
<class '__main__.User'>
{'name': 'Alice', 'age': 30, 'email': 'alice@example.com'}
cattrs.structure(data, MyClass) converts raw data into your class. cattrs.unstructure(obj) converts it back to a plain dict. The sections below cover nested types, optional fields, custom converters, and error handling.
What Is cattrs and Why Use It?
cattrs is a library for structuring and unstructuring data. “Structuring” means converting untyped data (dicts, lists) into typed Python objects. “Unstructuring” means the reverse — serializing objects back into plain data suitable for JSON or storage. It is designed to work with attrs classes but also supports standard dataclasses.
| Task | Manual code | cattrs |
|---|---|---|
| dict to class | Custom __init__ parsing | cattrs.structure(d, MyClass) |
| class to dict | Manual __dict__ extraction | cattrs.unstructure(obj) |
| Nested objects | Recursive manual code | Automatic via type hints |
| Type coercion | Manual int(), str() calls | Configurable hooks |
| Error messages | Generic KeyError/TypeError | Structured exceptions with path |
Install cattrs along with attrs:
# pip install cattrs attrs
import cattrs
print(cattrs.__version__)
23.2.3

Handling Nested Structures
Real data is rarely flat. APIs return nested objects — a user with an address, an order with line items. cattrs handles nesting automatically by following type hints recursively.
# cattrs_nested.py
import attrs
import cattrs
from typing import List
@attrs.define
class Address:
street: str
city: str
country: str
@attrs.define
class User:
name: str
age: int
address: Address
tags: List[str]
# Deeply nested raw data (e.g., from JSON API)
raw = {
"name": "Bob",
"age": 25,
"address": {
"street": "123 Main St",
"city": "Melbourne",
"country": "Australia"
},
"tags": ["admin", "verified"]
}
user = cattrs.structure(raw, User)
print(user.name)
print(user.address.city)
print(user.tags)
# Unstructure back to nested dict
print(cattrs.unstructure(user))
Output:
Bob
Melbourne
['admin', 'verified']
{'name': 'Bob', 'age': 25, 'address': {'street': '123 Main St', 'city': 'Melbourne', 'country': 'Australia'}, 'tags': ['admin', 'verified']}
The Address field is structured automatically because its type annotation is Address, which cattrs recognizes as an attrs class. The List[str] annotation tells cattrs to structure the list and coerce each element to a string. Deeply nested structures work the same way — cattrs follows the type tree recursively without any extra configuration.
Optional Fields and Union Types
Real-world data has missing fields. A user’s phone number might be null, a legacy record might omit a new field, or an API might return different types depending on context. cattrs handles Optional and Union types cleanly.
# cattrs_optional.py
import attrs
import cattrs
from typing import Optional, List, Union
@attrs.define
class Product:
id: int
name: str
price: float
description: Optional[str] = None
tags: Optional[List[str]] = None
# Missing optional fields default to None
raw_minimal = {"id": 1, "name": "Widget", "price": 9.99}
product = cattrs.structure(raw_minimal, Product)
print(product)
# Full data with optional fields populated
raw_full = {
"id": 2,
"name": "Gadget",
"price": 49.99,
"description": "A useful gadget",
"tags": ["electronics", "sale"]
}
product2 = cattrs.structure(raw_full, Product)
print(product2.description)
print(product2.tags)
Output:
Product(id=1, name='Widget', price=9.99, description=None, tags=None)
A useful gadget
['electronics', 'sale']
Optional[str] is equivalent to Union[str, None]. When the raw data contains None or the key is absent, cattrs uses the default value from the class definition. If you need to handle more complex unions — like a field that can be either a string or an integer depending on the context — you can register a custom hook (covered in the next section).
![Debug Dee examining Optional[str] and None puzzle pieces](https://pythonhowtoprogram.com/wp-content/uploads/2026/05/inline-img-2.jpg)
Custom Structuring Hooks
Sometimes the raw data does not match your type annotations exactly. A date might arrive as a string "2026-01-15" instead of a datetime object. A boolean might come as "true" or 1 instead of True. Custom hooks let you intercept the structuring process for specific types and apply your own conversion logic.
# cattrs_custom_hooks.py
import attrs
import cattrs
from datetime import datetime
@attrs.define
class Event:
title: str
start_date: datetime
attendees: int
# Create a custom converter (don't modify the global default)
converter = cattrs.Converter()
# Register a hook: convert string to datetime
def structure_datetime(value, _type):
if isinstance(value, datetime):
return value
if isinstance(value, str):
return datetime.fromisoformat(value)
raise ValueError(f"Cannot convert {value!r} to datetime")
converter.register_structure_hook(datetime, structure_datetime)
# Now structure data where date is a string
raw = {
"title": "PyCon AU",
"start_date": "2026-08-14T09:00:00",
"attendees": 500
}
event = converter.structure(raw, Event)
print(event.title)
print(event.start_date)
print(type(event.start_date))
Output:
PyCon AU
2026-08-14 09:00:00
<class 'datetime.datetime'>
Notice the use of cattrs.Converter() to create an isolated converter instance rather than using the global default. This keeps your custom hooks scoped to specific parts of your application without affecting other code that uses cattrs. The hook signature is always hook(value, type) — value is the raw input and type is the target Python type.
Validation and Error Handling
When structuring fails because the data does not match the expected types, cattrs raises a ClassValidationError. Unlike a bare TypeError, this exception contains structured information about exactly which fields failed and why — even for nested structures.
# cattrs_validation.py
import attrs
import cattrs
@attrs.define
class Config:
host: str
port: int
debug: bool
# Bad data -- port is a string that cann't be cast to int
bad_data = {"host": "localhost", "port": "not-a-number", "debug": True}
try:
config = cattrs.structure(bad_data, Config)
except cattrs.ClassValidationError as e:
print("Validation failed!")
for error in e.exceptions:
print(f" Field error: {error}")
Output:
Validation failed!
Field error: invalid literal for int() with base 10: 'not-a-number' (AttributeError) @ $.port
The @ $.port path notation shows exactly which field caused the problem using JSONPath-style notation. For deeply nested structures, you might see @ $.address.zip_code. This structured error reporting makes debugging data issues much faster than hunting through a generic traceback. In production, you can catch ClassValidationError, log the structured errors, and return a meaningful validation response to the API caller.

Real-Life Example: Parsing a Paginated API Response
Here is a complete example structuring a paginated API response with nested objects, optional fields, and a custom datetime hook:
# cattrs_api_parser.py
import attrs
import cattrs
from datetime import datetime
from typing import List, Optional
# Domain models
@attrs.define
class Author:
id: int
username: str
email: Optional[str] = None
@attrs.define
class Post:
id: int
title: str
body: str
author: Author
created_at: datetime
tags: List[str]
published: bool
@attrs.define
class PaginatedPosts:
total: int
page: int
per_page: int
posts: List[Post]
# Set up converter with datetime hook
converter = cattrs.Converter()
def parse_datetime(value, _):
if isinstance(value, datetime):
return value
return datetime.fromisoformat(value.replace("Z", "+00:00"))
converter.register_structure_hook(datetime, parse_datetime)
# Simulated response from jsonplaceholder.typicode.com (enriched)
api_response = {
"total": 100,
"page": 1,
"per_page": 2,
"posts": [
{
"id": 1,
"title": "Getting Started with Python",
"body": "Python is a versatile language...",
"author": {"id": 10, "username": "alice", "email": "alice@example.com"},
"created_at": "2026-01-15T10:30:00",
"tags": ["python", "beginner"],
"published": True
},
{
"id": 2,
"title": "Advanced asyncio Patterns",
"body": "Structured concurrency changes everything...",
"author": {"id": 11, "username": "bob"},
"created_at": "2026-02-20T14:00:00",
"tags": ["python", "async", "advanced"],
"published": True
}
]
}
# One line to structure the entire response
result = converter.structure(api_response, PaginatedPosts)
print(f"Page {result.page} of {result.total // result.per_page}")
for post in result.posts:
print(f"\n[{post.id}] {post.title}")
print(f" Author: {post.author.username} ({post.author.email or 'no email'})")
print(f" Posted: {post.created_at.strftime('%b %d, %Y')}")
print(f" Tags: {', '.join(post.tags)}")
Output:
Page 1 of 50
[1] Getting Started with Python
Author: alice (alice@example.com)
Posted: Jan 15, 2026
Tags: python, beginner
[2] Advanced asyncio Patterns
Author: bob (no email)
Posted: Feb 20, 2026
Tags: python, async, advanced
The entire multi-level API response — paginated wrapper, list of posts, nested author objects, datetime strings, optional email — structures in a single converter.structure() call. Adding a new field to the model automatically includes it in both structuring and unstructuring without changing any conversion code.
Frequently Asked Questions
How does cattrs compare to Pydantic?
Pydantic models are self-contained — validation logic lives in the class itself. cattrs is a separate converter layer that works with attrs classes or dataclasses. Pydantic V2 is extremely fast and has first-class FastAPI integration. cattrs is better suited for code that already uses attrs, needs highly customizable conversion behavior, or wants to separate the data model from the conversion logic. For new API-heavy projects, Pydantic is often the default choice; for attrs-based codebases, cattrs fits naturally.
Does cattrs work with standard dataclasses?
Yes. cattrs supports Python’s built-in dataclasses in addition to attrs classes. Use @dataclass from the standard library and cattrs will structure and unstructure them using the same API. The main difference is that attrs classes have more features (validators, converters, slots) that cattrs can leverage. For simple cases, dataclasses work fine.
Is cattrs fast enough for high-volume use?
cattrs generates optimized structuring code at registration time rather than inspecting types on every call. For high-volume scenarios, use the cattrs.gen.make_dict_structure_fn() pattern to pre-generate and cache structuring functions. In benchmarks, cattrs is significantly faster than manual dict parsing loops and competitive with Pydantic V1. For extreme performance requirements, consider Pydantic V2 with its Rust-based core.
How do I customize unstructuring (object to dict)?
Use converter.register_unstructure_hook(MyType, lambda obj: ...) to register a custom unstructuring function. For example, to serialize datetime as ISO format: converter.register_unstructure_hook(datetime, lambda dt: dt.isoformat()). You can also use cattrs.gen.make_dict_unstructure_fn() to generate an unstructure function with field overrides — useful for renaming keys or excluding fields from serialization.
What happens if a required field is missing?
If a required field (no default value) is absent from the raw data, cattrs raises a ClassValidationError with the path of the missing field. If you want to allow missing fields and use a fallback, either add a default value in your class definition (field: str = "default") or use Optional[str] = None. You can also register a structure hook that fills in defaults before structuring if the data source is known to be incomplete.
Conclusion
The cattrs library eliminates the repetitive, error-prone conversion code between raw data and typed Python objects. You learned how to structure and unstructure objects, handle nested types and optional fields, register custom hooks for special type conversions, and interpret structured validation errors. The paginated API parser showed how all these features combine into a clean, maintainable data layer.
The logical next step is to apply cattrs at the boundary of your application — wherever external data enters your system (API responses, config files, database results) — and convert it to typed objects immediately. From that point, the rest of your application works with typed, validated data and never touches raw dicts again. The catt.rs documentation covers advanced patterns including generating optimized converters, handling forward references, and customizing the global converter.