dattrs is a dataclass library. It is built on Python's descriptor protocol, it lets you define structured data with type enforcement, validation, and serialization—all without hidden runtime magic.
Clone the repository and ensure you have uv installed. If not, visit uv's installation guide.
Check available Python versions:
uv python listInstall Python (if needed):
uv python install 3.10Sync dependencies:
uv sync --dev
uv add tzdataLet's start simple. Here's how you define a dataclass with dattrs:
import dattrs
class Person(dattrs.Dataclass):
name = dattrs.field(str)
age = dattrs.field(int)
email = dattrs.field(str, allow_null=True, default=None)That's it. Now you can deserialize data into it:
person = dattrs.deserialize(Person, {
"name": "Alice",
"age": 30,
"email": "alice@example.com"
})
print(person.name) # Alice
print(person.age) # 30dattrs gives you two ways to define fields, depending on what feels right for your use case:
1. Using dattrs.field() (recommended for most cases)
class User(dattrs.Dataclass):
username = dattrs.field(str, max_length=50)
age = dattrs.field(int, min_value=0, max_value=120)2. Using Field classes directly
class User(dattrs.Dataclass):
username = dattrs.String(max_length=50)
age = dattrs.Integer(min_value=0, max_value=120)Both approaches work identically - dattrs.field() is just a smart factory that picks the right Field class for you. Use direct Field classes when you want to be explicit or need specialized fields like dattrs.Email, dattrs.IPAddress, or dattrs.DateTime.
Fields validate on assignment, not just during deserialization:
class Product(dattrs.Dataclass):
name = dattrs.field(str, min_length=3, max_length=100)
price = dattrs.field(float, min_value=0.0)
stock = dattrs.field(int, min_value=0)
product = Product(name="Widget", price=9.99, stock=100)
product.price = -5.0 # Raises ValidationError!from datetime import datetime
import random
class Article(dattrs.Dataclass):
title = dattrs.field(str)
content = dattrs.field(str)
published = dattrs.field(bool, default=False)
created_at = dattrs.field(datetime, default=datetime.now)
view_count = dattrs.field(int, default=0)
rating = dattrs.field(
float,
default=dattrs.Factory(random.random) # Generate random rating
)Use dattrs.Factory() when your default value needs to be computed or when you need to pass arguments to a callable. For simple immutable defaults like 0, False, or None, just use them directly.
You can create dataclass instances in two ways:
class User(dattrs.Dataclass):
name = dattrs.field(str)
age = dattrs.field(int)
email = dattrs.field(str, allow_null=True, default=None)
# Option 1: Direct instantiation (like regular classes)
user = User(name="Alice", age=30, email="alice@example.com")
# Or pass a dict
user = User({"name": "Alice", "age": 30})
# Or mix both
user = User({"name": "Alice"}, age=30)
# Option 2: Using deserialize (recommended)
user = dattrs.deserialize(User, {"name": "Alice", "age": 30})While both work, deserialize() is preferred because:
- It gives you more control with
InitConfigoptions - It's more explicit about data transformation
- The intent is clearer when reading code
Use direct instantiation for simple cases and when creating instances programmatically. Use deserialize() when loading external data (JSON, APIs, config files, etc.).
Nested structures just work:
class Address(dattrs.Dataclass):
street = dattrs.field(str)
city = dattrs.field(str)
country = dattrs.field(str, default="USA")
class Company(dattrs.Dataclass):
name = dattrs.field(str)
address = dattrs.field(Address)
company_data = {
"name": "Tech Corp",
"address": {
"street": "123 Main St",
"city": "San Francisco"
}
}
company = dattrs.deserialize(Company, company_data)
print(company.address.city) # San Franciscoimport enum
class Status(enum.Enum):
ACTIVE = "active"
INACTIVE = "inactive"
PENDING = "pending"
class Task(dattrs.Dataclass):
title = dattrs.field(str)
status = dattrs.field(dattrs.Choice[Status], default=Status.PENDING)
task = dattrs.deserialize(Task, {"title": "Deploy", "status": "active"})
print(task.status) # Status.ACTIVEfrom typing import List
class Team(dattrs.Dataclass):
name = dattrs.field(str)
members = dattrs.field(List[Person])
tags = dattrs.field(List[str], default=list)
team_data = {
"name": "Engineering",
"members": [
{"name": "Alice", "age": 30},
{"name": "Bob", "age": 25}
]
}
team = dattrs.deserialize(Team, team_data)
print(len(team.members)) # 2dattrs comes with a rich set of validators:
import dattrs.validators as v
class User(dattrs.Dataclass):
username = dattrs.field(
str,
validator=v.and_(
v.min_length(3),
v.max_length(20),
v.pattern(r"^[a-zA-Z0-9_]+$")
)
)
age = dattrs.field(int, validator=v.range_(18, 65))
email = dattrs.field(str, validator=v.pattern(r".+@.+\..+"))Available validators include:
gt(),gte(),lt(),lte(),eq()- Numeric comparisonsmin_length(),max_length(),length()- Length validationrange_()- Value range validationpattern()- Regex matchinginstance_of()- Type checkingmember_of()- Membership validationand_(),or_(),not_()- Logical compositionoptional()- Allow None valuesiterable(),mapping()- Collection validation
Write your own validators easily:
def is_even(value, adapter=None, *args, **kwargs):
if value % 2 != 0:
raise ValueError(f"{value} is not even")
class EvenNumbers(dattrs.Dataclass):
value = dattrs.field(int, validator=is_even)Chain validators together:
import dattrs.validators as v
class SecurePassword(dattrs.Dataclass):
password = dattrs.field(
str,
validator=v.and_(
v.min_length(8),
v.pattern(r".*[A-Z].*"), # Must have uppercase
v.pattern(r".*[0-9].*"), # Must have number
)
)from datetime import datetime, date
class Event(dattrs.Dataclass):
name = dattrs.field(str)
date = dattrs.field(date, input_formats=["%Y-%m-%d", "%d/%m/%Y"])
start_time = dattrs.field(datetime)
created_at = dattrs.field(datetime, default=datetime.now)class Contact(dattrs.Dataclass):
name = dattrs.field(str)
email = dattrs.Email()person = Person(name="Alice", age=30, email="alice@example.com")
# To Python dict
data = dattrs.serialize(person, fmt="python")
# To JSON-compatible dict
json_data = dattrs.serialize(person, fmt="json")Control field names in serialized output:
class User(dattrs.Dataclass):
internal_id = dattrs.field(int, serialization_alias="id")
user_name = dattrs.field(str, serialization_alias="username")
user = User(internal_id=1, user_name="alice")
data = dattrs.serialize(user, fmt="python", by_alias=True)
# {"id": 1, "username": "alice"}Fine-tune what gets serialized:
# Exclude specific fields
student_options = dattrs.Options(
dattrs.Option(Student, exclude={"internal_notes"}),
dattrs.Option(Course, recurse=False) # Don't serialize nested courses
)
serialized = dattrs.serialize(student, options=student_options)Only serialize fields that were explicitly set:
person = Person(name="Alice", age=30) # email not set
data = dattrs.serialize(person, exclude_unset=True)
# {"name": "Alice", "age": 30} # email excludedclass ImmutableUser(dattrs.Dataclass, frozen=True, hash=True):
id = dattrs.field(int)
username = dattrs.field(str)
# This will raise FrozenInstanceError
user = ImmutableUser(id=1, username="alice")
user.username = "bob" # Error!class Student(dattrs.Dataclass):
name = dattrs.field(str)
age = dattrs.field(int)
__config__ = dattrs.MetaConfig(
sort=True, # Sort fields alphabetically
repr=True, # Generate __repr__
frozen=False # Mutable instances
)class Person(dattrs.Dataclass):
name = dattrs.field(str)
age = dattrs.field(int)
class Employee(Person):
employee_id = dattrs.field(int)
department = dattrs.field(str)
# Employee has all Person fields plus its ownControl how data is deserialized:
# Use field names instead of aliases
person = dattrs.deserialize(
Person,
data,
config=dattrs.InitConfig(by_name=True)
)
# Fail fast on first error
person = dattrs.deserialize(
Person,
data,
config=dattrs.InitConfig(fail_fast=True)
)
# Skip validation (data already validated)
person = dattrs.deserialize(
Person,
data,
config=dattrs.InitConfig(is_valid=True)
)person = Person(name="Alice", age=30)
updated = dattrs.copy(person, update={"age": 31})This works similarly to copy with updates.
updated = dattrs.evolve(person, age=31)# Get a specific field
field = dattrs.get_field(Person, "name")
# Get all fields
fields = dattrs.get_fields(Person)
# Check if it's a dataclass
if dattrs.is_dataclass(Person):
print("It's a dataclass!")dattrs provides detailed error information to help you debug deserialization and validation issues quickly.
dattrsException - Base exception for all dattrs errors
ConfigurationError - Raised when there's an issue with dataclass or field configuration
# Example: Using both include and exclude in Options
dattrs.Option(MyClass, include={"a"}, exclude={"b"}) # ConfigurationError!FieldError - Raised for field-specific errors like invalid configuration
# Example: Invalid field type
class Bad(dattrs.Dataclass):
value = dattrs.field("not a type") # Will raise FieldError during buildValidationError - Raised when field validation fails
class User(dattrs.Dataclass):
age = dattrs.field(int, min_value=0)
user = User(age=30)
user.age = -5 # ValidationError: Value must be >= 0DeserializationError - Raised when deserialization fails (wraps multiple errors)
try:
person = dattrs.deserialize(Person, {
"name": "A", # Too short (min_length=3)
"age": -5 # Negative (min_value=0)
})
except dattrs.DeserializationError as e:
print(f"Parent: {e.parent_name}")
print(f"Total errors: {len(e.error_list)}")
for error in e.error_list:
print(f"\nLocation: {'.'.join(map(str, error.location))}")
print(f"Message: {error.message}")
print(f"Code: {error.code}")
print(f"Expected: {error.expected_type}")
print(f"Got: {error.input_type}")SerializationError - Raised when serialization fails
FrozenInstanceError - Raised when trying to modify a frozen dataclass
class Immutable(dattrs.Dataclass, frozen=True):
value = dattrs.field(int)
obj = Immutable(value=10)
obj.value = 20 # FrozenInstanceError!Every error comes with rich context via the ErrorDetail named tuple:
error = ErrorDetail(
location=["user", "address", "zipcode"], # Path to error
message="Invalid zipcode format", # Human-readable message
expected_type=str, # What was expected
input_type=int, # What was received
code="invalid_format", # Machine-readable error code
context={"pattern": r"\d{5}"}, # Additional context
origin=ValueError("...") # Original exception
)
# Get formatted string
print(error.as_string())
# user.address.zipcode
# Invalid zipcode format [input_type='int', expected_type='str', code='invalid_format', origin=ValueError]
# Get JSON representation
error_json = error.as_json()Common error codes you'll encounter:
invalid_type- Value doesn't match expected typecoercion_failed- Failed to convert value to target typevalidation_failed- Validator rejected the valuerequired_field- Required field is missingnull_not_allowed- None provided butallow_null=Falseinvalid_format- String format doesn't match patternvalue_too_small- Number belowmin_valuevalue_too_large- Number abovemax_valuelength_too_short- String/collection belowmin_lengthlength_too_long- String/collection abovemax_length
By default, dattrs collects all errors. Use fail_fast=True to stop at the first error:
# Collect all errors (default)
try:
data = dattrs.deserialize(ComplexClass, bad_data)
except dattrs.DeserializationError as e:
print(f"Found {len(e.error_list)} errors")
# Fail on first error
try:
data = dattrs.deserialize(
ComplexClass,
bad_data,
config=dattrs.InitConfig(fail_fast=True)
)
except dattrs.DeserializationError as e:
print(f"First error: {e.error_list[0].message}")Type adapters are dattrs's way of handling any Python type - not just the built-in ones. Think of them as translators that know how to deserialize, validate, and serialize a specific type.
When you use dattrs.field(list[int]), behind the scenes dattrs creates a TypeAdapter[list[int]] that knows how to:
- Deserialize: Convert strings like
["42", "43"]into an actual list of integers - Validate: Check that the value is actually an int
- Serialize: Convert the int back to regular and JSON-compatible formats
For custom types or complex generic types, you can create your own adapters.
from dattrs import TypeAdapter
import dattrs.validators as v
# Simple adapter with validation
age_adapter = TypeAdapter(
int,
name="Age",
validator=v.range_(0, 150),
strict=False # Allow coercion from strings
)
# Use it
value = age_adapter.adapt("25") # Deserializes and validates
print(value) # 25 (int)
# Or validate without deserialization
age_adapter.validate(25) # OK
age_adapter.validate(200) # ValidationError!Adapters shine with complex generic types:
from typing import List, Dict, Optional, Tuple
from collections import namedtuple
PersonTuple = namedtuple("PersonTuple", ["name", "age", "friends"])
# Adapter for complex nested structure
adapter = TypeAdapter(
Tuple[
List[Optional[PersonTuple]],
Dict[str, List[int]],
Optional[str]
],
defer_build=True # Resolve forward references later
)
# Build it when ready
adapter.build(globalns=globals(), depth=10)
# Now adapt complex data
raw_data = (
[
{"name": "Alice", "age": 30, "friends": []},
{"name": "Bob", "age": 25, "friends": []},
None
],
{"scores": [10, 20, 30]},
"metadata"
)
adapted = adapter.adapt(raw_data)
print(type(adapted[0][0])) # PersonTupleYou can pass adapters directly to fields:
# Create a reusable adapter
email_adapter = TypeAdapter(
str,
validator=v.pattern(r".+@.+\..+"),
deserializer=lambda v, f: v.lower().strip()
)
class User(dattrs.Dataclass):
email = dattrs.field(email_adapter)
# Or inline
verified = dattrs.field(
TypeAdapter(
bool,
deserializer=lambda v, f: str(v).lower() in ("1", "true", "yes")
)
)adapt(value) - Full pipeline: deserialize → validate → return
result = adapter.adapt("42") # Returns int(42)deserialize(value) - Convert to target type without validation
result = adapter.deserialize("42") # Returns int(42), no validationvalidate(value) - Validate an already-typed value
adapter.validate(42) # OK
adapter.validate("42") # ValidationErrorserialize(value, fmt) - Convert to output format
json_val = adapter.serialize(42, "json")
python_val = adapter.serialize(42, "python") # Usually unchangedbuild(...) - Build/resolve the adapter (for forward references)
adapter = TypeAdapter(
"MyClass", # Forward reference
defer_build=True
)
# Later, when MyClass is defined
adapter.build(globalns=globals())from datetime import datetime
def parse_timestamp(value, field):
"""Custom deserializer"""
if isinstance(value, int):
return datetime.fromtimestamp(value)
return datetime.fromisoformat(value)
def format_timestamp(value, field, context):
"""Custom serializer"""
return int(value.timestamp())
timestamp_adapter = TypeAdapter(
datetime,
deserializer=parse_timestamp,
serializers={
"json": format_timestamp,
"python": lambda v, f, ctx: v # Keep as datetime
}
)
class Event(dattrs.Dataclass):
created_at = dattrs.field(timestamp_adapter)Strict mode disables type coercion:
strict_int = TypeAdapter(int, strict=True)
strict_int.adapt(42) # OK
strict_int.adapt("42") # ValidationError: expected int, got str
lenient_int = TypeAdapter(int, strict=False)
lenient_int.adapt("42") # OK, returns 42Every field accepts these parameters (from FieldKwargs):
field_type - The expected Python type (required)
dattrs.field(int)
dattrs.field(List[str])
dattrs.field(Optional[datetime])strict (bool, default=False) - Only accept exact type, no coercion
value = dattrs.field(int, strict=True) # "42" will failvalidator (callable, optional) - Custom validation function
value = dattrs.field(int, validator=v.range_(0, 100))allow_null (bool, default=False) - Allow None values
email = dattrs.field(str, allow_null=True) # Can be Nonerequired (bool, default=False) - Must be explicitly provided
id = dattrs.field(int, required=True) # Can't use defaultdefault (value or Factory) - Default value when not provided
active = dattrs.field(bool, default=False)
created = dattrs.field(datetime, default=datetime.now)
items = dattrs.field(list, default=dattrs.Factory(list))validate_default (bool, default=False) - Validate the default value
# Useful to catch config errors early
value = dattrs.field(int, default=-5, min_value=0, validate_default=True) # Error!alias (str, optional) - Alternative name for deserialization
user_id = dattrs.field(int, alias="userId")
# {"userId": 123} deserializes to user_id=123serialization_alias (str, optional) - Alternative name for serialization
internal_id = dattrs.field(int, serialization_alias="id")
# Serializes as {"id": 123} instead of {"internal_id": 123}deserializer (callable, optional) - Custom deserialization function
def parse_date(value, field):
return datetime.strptime(value, "%Y-%m-%d")
date = dattrs.field(datetime, deserializer=parse_date)serializers (dict, optional) - Format-specific serializers
timestamp = dattrs.field(
datetime,
serializers={
"json": lambda v, f, ctx: v.isoformat(),
"python": lambda v, f, ctx: v
}
)always_coerce (bool, default=False) - Always run deserializer
# Even if value is already correct type
lower_str = dattrs.field(
str,
deserializer=lambda v, f: v.lower(),
always_coerce=True # Always lowercase
)check_coerced (bool, default=False) - Verify deserializer output type
# Safety check for custom deserializers
value = dattrs.field(int, deserializer=my_parser, check_coerced=True)skip_validator (bool, default=False) - Skip validation after deserialization
# Use when you trust the deserializer output
value = dattrs.field(int, skip_validator=True)fail_fast (bool, default=False) - Stop on first validation error
strict_field = dattrs.field(int, validator=v.range_(0, 100), fail_fast=True)init (bool, default=True) - Include in __init__ parameters
computed = dattrs.field(int, init=False) # Not in __init__repr (bool, default=True) - Include in __repr__ output
password = dattrs.field(str, repr=False) # Hidden in reprhash (bool, default=True) - Include in __hash__ calculation
id = dattrs.field(int, hash=True)
metadata = dattrs.field(dict, hash=False) # Not hashable anywayeq (bool, default=True) - Include in equality comparison
id = dattrs.field(int, eq=True)
timestamp = dattrs.field(datetime, eq=False) # Ignored in ==order (int >= 0, optional) - Ordering priority for comparisons
priority = dattrs.field(int, order=0) # Compared first
name = dattrs.field(str, order=1) # Compared secondclass Article(dattrs.Dataclass):
# Minimal
title = dattrs.field(str)
# With constraints
word_count = dattrs.field(int, min_value=0, max_value=100000)
# With validation
slug = dattrs.field(
str,
validator=v.pattern(r"^[a-z0-9-]+$"),
deserializer=lambda v, f: v.lower().replace(" ", "-")
)
# With defaults
published = dattrs.field(bool, default=False)
views = dattrs.field(int, default=0)
# With aliases
author_id = dattrs.field(
int,
alias="authorId",
serialization_alias="author"
)
# Complex
tags = dattrs.field(
List[str],
default=dattrs.Factory(list),
validator=v.and_(
v.min_length(1),
v.max_length(10)
)
)
# Internal use only
internal_notes = dattrs.field(
str,
allow_null=True,
default=None,
repr=False,
eq=False,
hash=False
)Configure dataclass behavior with class parameters or MetaConfig:
class MyClass(dattrs.Dataclass, frozen=True, hash=True, repr=True):
passclass MyClass(dattrs.Dataclass):
field1 = dattrs.field(str)
__config__ = dattrs.MetaConfig(
frozen=True,
hash=True,
repr=True
)frozen (bool, default=False) - Make instances immutable
class ImmutableUser(dattrs.Dataclass, frozen=True):
id = dattrs.field(int)
user = ImmutableUser(id=1)
user.id = 2 # FrozenInstanceError!slots (bool or tuple, default=False) - Use __slots__ for memory efficiency
# Boolean: automatic slots
class Compact(dattrs.Dataclass, slots=True):
pass
# Tuple: add custom slots
class Custom(dattrs.Dataclass, slots=("_cache", "_state")):
passrepr (bool, default=False) - Generate __repr__ method
class User(dattrs.Dataclass, repr=True):
name = dattrs.field(str)
age = dattrs.field(int)
print(User(name="Alice", age=30))
# User(name='Alice', age=30)str (bool, default=False) - Generate __str__ method
class User(dattrs.Dataclass, str=True):
name = dattrs.field(str)hash (bool, default=False) - Generate __hash__ method
# Usually paired with frozen=True
class HashableUser(dattrs.Dataclass, frozen=True, hash=True):
id = dattrs.field(int)
users = {HashableUser(id=1), HashableUser(id=2)} # Can use in setseq (bool, default=True) - Generate __eq__ method
class User(dattrs.Dataclass, eq=True):
id = dattrs.field(int)
User(id=1) == User(id=1) # Trueorder (bool, default=False) - Generate comparison methods (__lt__, __le__, etc.)
class Priority(dattrs.Dataclass, order=True):
level = dattrs.field(int, order=0)
name = dattrs.field(str, order=1)
Priority(level=1, name="Low") < Priority(level=2, name="High") # Truesort (bool or callable, default=False) - Sort fields
# Sort alphabetically
class Sorted(dattrs.Dataclass, sort=True):
pass
# Custom sort key
class CustomSort(dattrs.Dataclass, sort=lambda item: item[1].order):
passgetitem (bool, default=False) - Enable __getitem__ access
class User(dattrs.Dataclass, getitem=True):
name = dattrs.field(str)
user = User(name="Alice")
print(user["name"]) # Alicesetitem (bool, default=False) - Enable __setitem__ assignment
class User(dattrs.Dataclass, setitem=True):
name = dattrs.field(str)
user = User(name="Alice")
user["name"] = "Bob"pickleable (bool, default=True) - Add pickle support methods
import pickle
class Pickleable(dattrs.Dataclass, pickleable=True):
data = dattrs.field(dict)
obj = Pickleable(data={"key": "value"})
pickled = pickle.dumps(obj)
restored = pickle.loads(pickled)Contributions are welcome! Please fork the repository and submit a pull request with your changes. Make sure to include tests for any new features or bug fixes.