cheat sheet
attrs
Package-level reference for attrs on PyPI — install, @define vs @attr.s, validators, converters, and comparison with dataclasses.
attrs
What it is
attrs is a Python library by Hynek Schlawack for declarative class definitions — fields, init, repr, eq, hash, ordering, immutability, slots — without writing boilerplate. It predates the stdlib dataclasses module (which it directly inspired in PEP 557) and remains the more powerful option: it has converters, multi-step validators, factory defaults that see the instance, slotted classes by default, on-setattr hooks, deep @define/@frozen ergonomics, and attrs.evolve() / attrs.asdict() helpers.
Reach for attrs when: you need converters or validators (dataclasses do neither natively), you want slots without writing __slots__, you're building an SDK and want stable, version-tested class semantics, or you're already using cattrs for structured (de)serialization. For tiny "bag of fields" cases, stdlib dataclasses is simpler.
Install
pip install attrs
Output: (none — exits 0 on success)
uv add attrs
Output: resolved + added to pyproject.toml
poetry add attrs
Output: updated lockfile + virtualenv install
Pure-Python wheel — no compile step, no native deps. The PyPI package name is attrs; the import is import attrs (modern API) or import attr (legacy API). Both ship in the same wheel.
Versioning & Python support
- Current line is the
25.xseries in 2025-26 (calendar-versioned since21.1.0). - Supports Python 3.8+ on recent releases.
- Calendar versioning + a strict deprecation policy means upgrades are safe; nothing removed for at least two calendar years after deprecation notice.
- Pin tight in libraries (
attrs>=23,<26) and let applications float more freely.
Package metadata
- Maintainer: Hynek Schlawack + community
- Project home: github.com/python-attrs/attrs
- Docs: attrs.org
- PyPI: pypi.org/project/attrs
- License: MIT
- Governance: community-led under
python-attrsGitHub org - First released: 2015
- Downloads: hundreds of millions per month — transitive via
aiohttp,pytest,jsonschema, many SDKs
Optional dependencies & extras
attrs has zero hard runtime deps. Documented extras for development:
attrs[tests]—pytest,hypothesis, etc., for the project's own test suite.attrs[docs]— Sphinx and friends.
The companion cattrs package is a separate install (pip install cattrs) for structured (de)serialization to/from JSON, msgpack, etc.
Alternatives
| Package | Trade-off |
|---|---|
dataclasses (stdlib) | Zero install; no converters, no validators, slots only via 3.10+ slots=True. Best for trivial cases. |
pydantic | Run-time validation + JSON schema; slower constructor than attrs. Use when you parse external data into models. |
msgspec | Schema-first like pydantic but ~10× faster; cstruct-style. Newer, narrower ecosystem. |
typing.NamedTuple | Immutable tuples-with-names; fewer features. |
dataclass + __post_init__ | Manual validator pattern; works but you reinvent attrs. |
pydantic.dataclasses | Pydantic-validated dataclasses; bridges the two worlds. |
Common gotchas
- Two APIs in one package. Modern:
from attrs import define, field, frozen. Legacy:import attr; @attr.s(auto_attribs=True); attr.ib(...). Don't mix in the same module. @defineenables slots by default. That breaks__dict__-based monkey-patching and multiple inheritance with non-slotted classes. Use@define(slots=False)to opt out.- Default values are NOT factories.
field(default=[])shares the same list across instances. Usefield(factory=list)orfield(default=Factory(list)). - Validators run at construction time only. Subsequent
obj.field = xdoes NOT re-run them unless you opt intoon_setattr. - Converters run BEFORE validators. A converter that returns a wrong-typed value will still pass through to the validator.
@frozenraises on__setattr__but doesn't prevent attribute mutation on contained objects. Freezing is shallow.attrs.evolve(obj, **changes)is the immutable-update primitive. It re-validates — handy for "I want a copy with one field changed".attr.s(legacy) doesn't infer types from annotations unless you passauto_attribs=True. Modern@definealways infers.
Real-world recipes
The recipes below show the modern API patterns: declarative classes, validators, converters, frozen + slots, and evolve. Skip the legacy @attr.s form unless you're maintaining old code.
Recipe 1 — Declarative class with @define.
from attrs import define, field
@define
class User:
id: int
name: str
email: str = field(default="")
roles: list[str] = field(factory=list)
u = User(id=1, name="Alice Dev", email="alice@example.com")
print(u)
Output: User(id=1, name='Alice Dev', email='alice@example.com', roles=[]) — auto-generated __init__, __repr__, __eq__, __hash__ (if frozen) all configured.
Recipe 2 — Field validators.
from attrs import define, field
from attrs.validators import instance_of, ge, max_len
@define
class Score:
name: str = field(validator=[instance_of(str), max_len(50)])
value: int = field(validator=[instance_of(int), ge(0)])
Score("Alice Dev", 92)
# Score("Alice Dev", -1) # raises ValueError
Output: the second line (uncommented) raises ValueError: 'value' must be >= 0 — composable validators short-circuit on first failure.
Recipe 3 — Converters: normalize on input.
from attrs import define, field
@define
class Tag:
name: str = field(converter=lambda s: s.strip().lower())
print(Tag(" Python ").name)
Output: python — converter normalizes whitespace and case at construction.
Recipe 4 — Frozen + slots immutable class.
from attrs import frozen
@frozen
class Point:
x: float
y: float
p = Point(1.0, 2.0)
# p.x = 5 # raises FrozenInstanceError
print({p, Point(1.0, 2.0)}) # hashable
Output: {Point(x=1.0, y=2.0)} — frozen → hashable; deduped because __eq__ is value-based.
Recipe 5 — Immutable update with evolve.
from attrs import define, field, evolve
@define
class Settings:
host: str
port: int = 8080
tls: bool = False
s1 = Settings(host="myhost")
s2 = evolve(s1, port=443, tls=True)
print(s1, s2)
Output: Settings(host='myhost', port=8080, tls=False) Settings(host='myhost', port=443, tls=True) — original untouched, copy is re-validated.
Recipe 6 — Compare with dataclasses head-to-head.
from dataclasses import dataclass, field as dc_field
from attrs import define, field as a_field
@dataclass(slots=True, frozen=True)
class A:
name: str
tags: list = dc_field(default_factory=list) # stdlib factory syntax
@define(frozen=True)
class B:
name: str
tags: list = a_field(factory=list) # attrs factory syntax
# attrs adds: converter=, validator=, on_setattr=, Factory(takes_self=True)
Output: functionally close for simple cases; attrs wins when you need converters, validators, or takes_self factories that see the partial instance.
Production deployment notes
- Use the modern API.
@define/@frozen/fieldfrom theattrsnamespace. Reserveattr.sfor legacy codebases. - Slots ON by default with
@define. This is usually what you want — smaller memory, faster attribute access, surface bugs early. Opt out only when you need monkey-patchable instances. - Use
cattrsfor (de)serialization, not hand-rolledto_dict()methods. It handles unions, generics, and recursive structures. - Type-check with mypy. The
attrsmypy plugin (mypy-extensionsintegration) tightens validation; alternatively the typed dataclass-style API works out of the box with mypy and pyright. - Watch for
__attrs_post_init__— equivalent to dataclass__post_init__. Useful for cross-field invariants the per-field validators can't express.
Performance tuning
@defineis faster thandataclasseson__init__in benchmarks (microseconds matter only if you create millions of instances).- Slots = faster attribute access. ~10-20 % speedup on attribute reads.
- Avoid
eq=Trueon big collections. Comparing two large list-fields is O(N); seteq=Falseon fields that don't matter. on_setattr=adds overhead. Default is none; only enable for fields that genuinely need re-validation.asdict()is recursive. For very deep object graphs, prefercattrs(faster, configurable).
Version migration guide
< 20.x—@attr.swas the only API. Legacy.20.x → 21.x— modern@defineAPI stabilized; calendar versioning started.22.x → 23.x— typing improvements;attrs.fieldgotalias=parameter.24.x → 25.x— minor; full Python 3.13 support, free-threaded build OK.
# Legacy (pre-20)
import attr
@attr.s(auto_attribs=True, frozen=True)
class Point:
x: float = attr.ib()
y: float = attr.ib()
# Modern (20+)
from attrs import frozen
@frozen
class Point:
x: float
y: float
Output: identical runtime behavior; modern form is shorter and pyright-friendlier.
Security considerations
- Validators are NOT input sanitizers. They check types/shapes, not safety. For untrusted input (web bodies, RPC payloads), use
pydanticorcattrswith explicit converters. - Converters can be arbitrary callables. Don't construct an attrs class from user-controlled
cls(**body)without rejecting unknown keys (cls(**{**body, "extra": "..."})raisesTypeError, but you may have already started side effects in converters). - Frozen is shallow. A frozen object holding a list lets the list mutate. Use tuples/frozensets for immutable contents.
- Hash + mutate footgun. Frozen → hashable; if you bypass
__setattr__viaobject.__setattr__, the hash drifts. Don't.
Testing & CI integration
attrsplays well withhypothesisfor property-based tests on dataclass-shaped objects.- Use
attrs.asdictin test fixtures rather than custom serializers; consistent across the codebase. cattrshas structured-test helpers if you're validating round-trip (de)serialization.
from attrs import define, field
from attrs.validators import ge
@define
class Score:
value: int = field(validator=ge(0))
def test_score_validates():
import pytest
with pytest.raises(ValueError):
Score(-1)
Output: test passes; validator surface verified.
Ecosystem integrations
cattrs— structured (de)serialization, JSON / msgpack / etc.pytest—pytestitself usesattrsinternally for fixtures.aiohttp— request/response classes useattrs.jsonschema— schema validator built on attrs.environ-config—attrs-based env-var configuration.structlog— internal event-dict types.mypyplugin — bundled; integrates with type-checkers automatically.pyright— supportsattrsvia dataclass-transform.
Compatibility matrix
| Python | attrs line | Notes |
|---|---|---|
| 3.7 | 22.x | Floor for older Python. |
| 3.8 | 23.x+ | Current floor for recent releases. |
| 3.9 | 23.x+ | Supported. |
| 3.10 | 23.x+ | dataclass_transform integration. |
| 3.11 | 23.x+ | Supported. |
| 3.12 | 23.x+ | PEP 695 generics interop. |
| 3.13 | 25.x | Free-threaded build supported. |
Troubleshooting common errors
| Error / Symptom | Likely cause | Fix |
|---|---|---|
AttributeError: can't set attribute on @define class | slots=True is on, attribute not declared | Declare it via field() or set @define(slots=False). |
| Validator never triggered after mutation | Default on_setattr is no-op | Set @define(on_setattr=attrs.setters.validate). |
| Shared mutable default across instances | Used field(default=[]) | Use field(factory=list). |
FrozenInstanceError from a method that "should be const" | @frozen blocks all __setattr__ | Use attrs.evolve(self, ...) instead of mutating. |
TypeError: missing required argument after refactor | Default removed; positional reordering | Use kw-only fields: field(kw_only=True). |
| mypy doesn't see attrs fields | Plugin not configured | Newer mypy auto-detects via dataclass_transform; older needs [mypy] config. |
attrs.asdict recursion error | Cycle in object graph | Use cattrs.GenConverter with cycle handling. |
When NOT to use this
- Trivial classes with no validation/converters. stdlib
dataclassesis one fewer dep. - Web API models with rich validation.
pydanticis purpose-built for that. - Performance-critical hot paths with simple data. Use
msgspecfor ~10× faster (de)serialization. - Tuples are sufficient. A bare
NamedTupleis half the code. - You need C-struct layout for ctypes. Use
ctypes.Structure.
Worked example: domain model with validation, conversion, and serialization
A realistic pattern — value-objects with input normalization, business-rule validation, immutable updates, and JSON (de)serialization via cattrs.
Step 1 — define the model.
from attrs import define, field, frozen
from attrs.validators import instance_of, ge, max_len, in_
from datetime import datetime
VALID_ROLES = {"admin", "member", "viewer"}
@frozen
class User:
id: int = field(validator=[instance_of(int), ge(1)])
email: str = field(
validator=[instance_of(str), max_len(255)],
converter=lambda s: s.strip().lower(),
)
role: str = field(validator=in_(VALID_ROLES), default="viewer")
created_at: datetime = field(factory=datetime.utcnow)
u = User(id=1, email=" Alice@Example.com ", role="admin")
print(u.email, u.role)
Output: alice@example.com admin — converter normalized casing/whitespace; validator restricted role to the allow-list.
Step 2 — immutable updates via evolve.
from attrs import evolve
u2 = evolve(u, role="viewer") # re-validates; original unchanged
print(u.role, u2.role)
Output: admin viewer — same identity-ish object semantics without the mutation surface.
Step 3 — JSON round-trip with cattrs.
# pip install cattrs
from cattrs import Converter
from cattrs.preconf.json import make_converter
conv = make_converter()
payload = conv.unstructure(u) # → dict
restored = conv.structure(payload, User) # ← dict
print(payload)
print(restored == u)
Output: the dict shape matches the field names; restored == u is True because frozen+slots+eq gives value equality.
Step 4 — cross-field invariants via __attrs_post_init__.
@define
class DateRange:
start: datetime
end: datetime
def __attrs_post_init__(self):
if self.end < self.start:
raise ValueError("end must be >= start")
Output: field validators check per-field; post-init checks cross-field invariants. Both run at construction.
Step 5 — testing the model.
import pytest
def test_email_normalized():
u = User(id=1, email="ALICE@example.com")
assert u.email == "alice@example.com"
def test_role_validated():
with pytest.raises(ValueError):
User(id=1, email="alice@example.com", role="superadmin")
Output: both tests pass; documents the normalization + validation contract.
FAQ
Q: When should I prefer attrs over stdlib dataclasses?
A: You want converters, validators, or Factory(takes_self=True). Otherwise dataclasses with slots=True cover the basics with one fewer dep.
Q: When should I prefer pydantic?
A: Untrusted input (HTTP bodies, RPC payloads, CLI args). Pydantic's validation is built around parsing external data into models; attrs is built around in-process value objects.
Q: How does cattrs compare to pydantic.BaseModel.parse_obj?
A: cattrs is faster but more configuration-heavy. Pydantic is more batteries-included. Mixed-use codebases: attrs + cattrs for internal domain, pydantic for API surfaces.
Q: Can I use attrs with generic types?
A: Yes. @define works with Generic[T]. Type checkers (mypy, pyright) understand it via dataclass_transform.
Q: How do I expose attrs classes via JSON Schema?
A: Use cattrs.preconf.orjson plus the optional attrs-jsonschema add-on, or generate by walking attrs.fields(cls) yourself. Native JSON Schema support is a pydantic-specific feature.
Q: My validators are slow — can I skip them in trusted code paths?
A: Set @define(field_transformer=lambda fields: [...]) to strip validators, or build a parallel "trusted constructor" classmethod that uses __new__ + object.__setattr__. Rare in practice.
See also
- Concept: API — class-based vs functional API design