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

bash
pip install attrs

Output: (none — exits 0 on success)

bash
uv add attrs

Output: resolved + added to pyproject.toml

bash
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.x series in 2025-26 (calendar-versioned since 21.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-attrs GitHub 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

PackageTrade-off
dataclasses (stdlib)Zero install; no converters, no validators, slots only via 3.10+ slots=True. Best for trivial cases.
pydanticRun-time validation + JSON schema; slower constructor than attrs. Use when you parse external data into models.
msgspecSchema-first like pydantic but ~10× faster; cstruct-style. Newer, narrower ecosystem.
typing.NamedTupleImmutable tuples-with-names; fewer features.
dataclass + __post_init__Manual validator pattern; works but you reinvent attrs.
pydantic.dataclassesPydantic-validated dataclasses; bridges the two worlds.

Common gotchas

  1. 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.
  2. @define enables slots by default. That breaks __dict__-based monkey-patching and multiple inheritance with non-slotted classes. Use @define(slots=False) to opt out.
  3. Default values are NOT factories. field(default=[]) shares the same list across instances. Use field(factory=list) or field(default=Factory(list)).
  4. Validators run at construction time only. Subsequent obj.field = x does NOT re-run them unless you opt into on_setattr.
  5. Converters run BEFORE validators. A converter that returns a wrong-typed value will still pass through to the validator.
  6. @frozen raises on __setattr__ but doesn't prevent attribute mutation on contained objects. Freezing is shallow.
  7. attrs.evolve(obj, **changes) is the immutable-update primitive. It re-validates — handy for "I want a copy with one field changed".
  8. attr.s (legacy) doesn't infer types from annotations unless you pass auto_attribs=True. Modern @define always 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.

python
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.

python
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.

python
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.

python
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.

python
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.

python
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 / field from the attrs namespace. Reserve attr.s for 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 cattrs for (de)serialization, not hand-rolled to_dict() methods. It handles unions, generics, and recursive structures.
  • Type-check with mypy. The attrs mypy plugin (mypy-extensions integration) 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

  • @define is faster than dataclasses on __init__ in benchmarks (microseconds matter only if you create millions of instances).
  • Slots = faster attribute access. ~10-20 % speedup on attribute reads.
  • Avoid eq=True on big collections. Comparing two large list-fields is O(N); set eq=False on 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, prefer cattrs (faster, configurable).

Version migration guide

  • < 20.x@attr.s was the only API. Legacy.
  • 20.x → 21.x — modern @define API stabilized; calendar versioning started.
  • 22.x → 23.x — typing improvements; attrs.field got alias= parameter.
  • 24.x → 25.x — minor; full Python 3.13 support, free-threaded build OK.
python
# 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 pydantic or cattrs with 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": "..."}) raises TypeError, 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__ via object.__setattr__, the hash drifts. Don't.

Testing & CI integration

  • attrs plays well with hypothesis for property-based tests on dataclass-shaped objects.
  • Use attrs.asdict in test fixtures rather than custom serializers; consistent across the codebase.
  • cattrs has structured-test helpers if you're validating round-trip (de)serialization.
python
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.
  • pytestpytest itself uses attrs internally for fixtures.
  • aiohttp — request/response classes use attrs.
  • jsonschema — schema validator built on attrs.
  • environ-configattrs-based env-var configuration.
  • structlog — internal event-dict types.
  • mypy plugin — bundled; integrates with type-checkers automatically.
  • pyright — supports attrs via dataclass-transform.

Compatibility matrix

Pythonattrs lineNotes
3.722.xFloor for older Python.
3.823.x+Current floor for recent releases.
3.923.x+Supported.
3.1023.x+dataclass_transform integration.
3.1123.x+Supported.
3.1223.x+PEP 695 generics interop.
3.1325.xFree-threaded build supported.

Troubleshooting common errors

Error / SymptomLikely causeFix
AttributeError: can't set attribute on @define classslots=True is on, attribute not declaredDeclare it via field() or set @define(slots=False).
Validator never triggered after mutationDefault on_setattr is no-opSet @define(on_setattr=attrs.setters.validate).
Shared mutable default across instancesUsed 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 refactorDefault removed; positional reorderingUse kw-only fields: field(kw_only=True).
mypy doesn't see attrs fieldsPlugin not configuredNewer mypy auto-detects via dataclass_transform; older needs [mypy] config.
attrs.asdict recursion errorCycle in object graphUse cattrs.GenConverter with cycle handling.

When NOT to use this

  • Trivial classes with no validation/converters. stdlib dataclasses is one fewer dep.
  • Web API models with rich validation. pydantic is purpose-built for that.
  • Performance-critical hot paths with simple data. Use msgspec for ~10× faster (de)serialization.
  • Tuples are sufficient. A bare NamedTuple is 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.

python
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.

python
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.

python
# 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__.

python
@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.

python
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