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.

TaskManual codecattrs
dict to classCustom __init__ parsingcattrs.structure(d, MyClass)
class to dictManual __dict__ extractioncattrs.unstructure(obj)
Nested objectsRecursive manual codeAutomatic via type hints
Type coercionManual int(), str() callsConfigurable hooks
Error messagesGeneric KeyError/TypeErrorStructured exceptions with path

Install cattrs along with attrs:

# pip install cattrs attrs

import cattrs
print(cattrs.__version__)
23.2.3
API Alice sorting plain dicts into typed containers
cattrs.structure(): because raw dicts are just data pretending to have a schema.

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
Optional[str] = None: the polite way to say ‘this field might ghost you’.

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.

Stack Trace Steve holding ClassValidationError report
ClassValidationError @ $.port — not ‘something went wrong’, but where and what.

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.