attrs removes repetitive class code without obscuring the model. It generates initialization, representation, comparisons, and related methods, while validators and converters state input rules directly. It becomes valuable when a Python dataclass starts accumulating manual checks.
Build a class with attrs
Install the package and use its modern API:
python -m pip install attrs
from decimal import Decimal
from attrs import define, field, validators
@define(frozen=True, slots=True)
class Product:
name: str = field(converter=str.strip)
price: Decimal = field(
converter=Decimal,
validator=validators.gt(Decimal("0")),
)
product = Product(" Python Course ", "149.90")
assert product.name == "Python Course"
define creates the class, frozen=True prevents reassignment, and slots=True avoids a per-instance dictionary. Conversion happens before validation, so the validator receives a Decimal even when the caller supplies a string.
Validate relationships between fields
Field validators work for local rules. Check an invariant involving several attributes after initialization:
from attrs import define
@define
class Range:
start: int
end: int
def __attrs_post_init__(self) -> None:
if self.end < self.start:
raise ValueError("end must be greater than or equal to start")
Do not use converters to silently repair ambiguous input. Reject invalid state whenever a correction would change meaning. Annotations are not runtime checks either; the Python type hints guide explains their static role.
attrs or dataclasses
Choose dataclasses when the standard library fully covers the model and dependency count matters. Consider attrs for composable validators, converters, field aliases, controlled constructor evolution, or an existing codebase already using it.
Avoid migrating for style alone. Compare equality, hashing, mutability, serialization, and the public signature. Tests should cover valid and invalid input before converters are introduced into an existing class.
The official attrs documentation, accessed July 22, 2026, covers classes, fields, validators, converters, and compatibility. Check it against the version pinned by your project.
Practical checklist
- Choose mutability and slots deliberately.
- Reserve converters for unambiguous transformations.
- Keep invariants close to the model.
- Return clear errors to callers.
- Test the public constructor before evolving it.
The main value of attrs is not fewer keystrokes. It is keeping construction, normalization, and invariants in one testable contract.
Safe defaults and factories
Mutable defaults must not be shared between instances. Use a factory:
from attrs import define, field
@define
class Order:
customer_id: int
items: list[str] = field(factory=list)
first = Order(1)
second = Order(2)
first.items.append("book")
assert second.items == []
A factory may use takes_self=True, but that couples it to initialization order. Prefer a property when a derived value does not need storage.
Composable validators
attrs.validators includes type, membership, length, optional-value, and comparison checks:
from attrs import define, field, validators
@define
class User:
name: str = field(
converter=str.strip,
validator=[validators.instance_of(str), validators.min_len(2)],
)
role: str = field(validator=validators.in_({"member", "admin"}))
Runtime type checks can protect internal boundaries, but they are not complete validation for an external payload. Handle missing fields, formats, and protocol-friendly errors before construction. If None is valid, express that decision in both the annotation and validators.optional(...).
Custom validators receive the instance, attribute, and value. Keep their errors specific without echoing secrets or an entire incoming document.
Conversion is not validation
A converter should normalize an unambiguous representation. Stripping text, building an enum, or converting to Decimal can be reasonable. Do not convert arbitrary input with bool: bool("false") is True. Do not silently round money or repair identifiers unless that behavior is the documented contract.
Conversion runs before validation. Test this pipeline whenever the class is public. Changing a converter can affect equality, hashing, and serialized output even if the annotation remains unchanged.
Private fields, aliases, and API evolution
An attribute named _token normally appears as token in the generated initializer. The modern API supports alias when that parameter name must be controlled. An alias does not automatically preserve both the old and new names.
Prefer named arguments and consider keyword-only fields for future growth:
from attrs import define, field
@define
class Settings:
endpoint: str
timeout: float = field(default=5.0, kw_only=True)
Adding an optional keyword-only field is safer than rearranging positional parameters. Signature tests are worthwhile for a library or widely used domain model.
Frozen objects, slots, and hashes
frozen=True blocks normal assignment, but it does not make a contained list immutable. Use tuples and frozenset when the complete value must remain stable. Hashable objects must not mutate fields that contribute to their hash.
slots=True prevents accidental attributes and can reduce per-instance memory. Before enabling it on an existing class, test inheritance, introspection, weak references, pickling, and tools that expect __dict__. Choose it from application needs and measurements, not a generic microbenchmark.
Let attrs derive equality and hashing unless domain identity requires a deliberate alternative. Document that decision because it affects sets, dictionaries, and caches.
Explicit serialization
attrs.asdict() recursively produces dictionaries by default. It can copy a large object graph and disclose internal fields:
from attrs import asdict, filters
public = asdict(user, filter=filters.exclude("token"))
Do not treat asdict() as an automatic JSON contract. Dates, decimals, enums, and custom values still need an encoding policy. For durable APIs, build an explicit DTO or use tested filters and transformations. Never expose credentials merely because they are attributes.
attrs.evolve(object, field=value) creates a modified copy and runs initialization, converters, and validators again. It is useful with frozen models, but verify the handling of derived and init=False fields.
Inheritance versus composition
Data-class inheritance introduces ordering constraints between required and defaulted attributes. Keyword-only fields solve some cases, but composition often models the domain more clearly. An order can contain an address and a pricing policy without inheriting from either.
When inheritance is necessary, test the final constructor and __attrs_post_init__ chain. Consult the installed-version documentation before combining attrs classes with ordinary base classes.
Tests that protect the contract
- valid construction with named arguments;
- rejection of each invariant;
- conversion before validation;
- independent mutable factories;
- equality, ordering, and hashing where public;
- representations without sensitive data;
- evolution of frozen instances;
- serialization containing only public fields;
- static type checking separately from runtime validation.
Assert behavior rather than the complete generated repr, unless its exact form is intentionally public. This makes upgrades less brittle.
Gradual migration
Before replacing a manual class, capture its signature, defaults, equality, and error behavior. Convert one class at a time and retain characterization tests. New code should generally prefer the modern define, field, and frozen APIs.
Avoid mixing several modeling libraries at one boundary without a concrete reason. attrs models Python objects; external-document validation, ORM behavior, and schema generation are separate concerns. Choose it for the contract you need, not merely for fewer lines.
Operational considerations
Keep validation deterministic and free of network or database access. Construction should not unexpectedly become an I/O operation. Resolve external facts in a service, then pass the result into the model. This makes errors predictable and keeps object creation usable in tests, scripts, and background jobs.
Generated repr output is excellent for debugging but may include personal information. Mark sensitive fields with repr=False and still sanitize logs at their boundary. Likewise, use eq=False for operational metadata only after deciding whether two objects with different metadata should compare equal.
When static typing matters, run the project's type checker against representative constructors. attrs exposes metadata understood by modern typing tools, yet runtime behavior ultimately depends on installed versions and options. Keep dependency upgrades reviewed, read their changelog, and exercise public signatures before release.
Good models reject invalid states early without trying to own every application concern. A concise attrs class with explicit converters and validators is easier to maintain than a large class that parses transport documents, queries storage, serializes responses, and implements domain policy at once.