attrs
attrs writes your Python class boilerplate for you. You decorate a class with @define, declare attributes with type annotations, and attrs generates __init__, __repr__, equality methods, hashing, and more at class-creation time, so there is no runtime penalty per instance. It predates and inspired the standard library's dataclasses, and goes further: validators and converters that run on init and on assignment, frozen and slotted classes by default, evolve() for copies with changes, and hooks into almost every step of initialization. NASA has used it on Mars mission software.
The best pure class-boilerplate generator in Python: faster to write, stricter, and more extensible than dataclasses, without pydantic's runtime weight. Pick it when your classes are internal objects with invariants; pick pydantic instead when your job is validating external data at the boundary.
Use it if
- You write many small data-holding classes and want __init__, __repr__, and __eq__ generated correctly without copy-paste dunder methods
- You need validation or conversion on attribute assignment, not just at construction time; @define wires validators and converters into setattr by default
- You want slotted classes for lower memory and attribute-typo protection without hand-writing __slots__ on every class
- You outgrew dataclasses: attrs adds validators, converters, custom equality for things like NumPy arrays, and initialization hooks dataclasses does not have
- Your classes are plain records with no validation: stdlib dataclasses does that with zero dependencies, and every Python dev already knows it
- Your real job is parsing and serializing external data (JSON APIs, settings): pydantic validates deeply and serializes out of the box, while attrs needs the separate cattrs library for structured (de)serialization
- Your team will trip on the two API generations: @attr.s with attr.ib() and @define with field() coexist in old codebases and tutorials, and mixing them confuses reviews
- Slots-by-default bites your patterns: @define classes reject dynamic attribute assignment and complicate some mixin, pickling, and monkeypatching tricks; you can pass slots=False, but you have to know that is the cause
Setup reality
pip install attrs; pure Python, no compiled parts, supports Python 3.9+. The setup cost is conceptual, not mechanical. There are two package names (attrs is the modern API, attr the classic one both are importable) and two decorator generations, so reading old code requires knowing @attr.s and @define are the same machinery. Mutable defaults must go through Factory or field(factory=...) or every instance shares one list. Slots-by-default in @define changes pickle and inheritance edge cases compared to plain classes. Type checkers understand attrs via a plugin baked into mypy and pyright support, which mostly just works.
Patterns
Define a class with generated init, repr, and eqdefine-basic-class
from attrs import define
@define
class Point:
x: int
y: int = 0
p = Point(1, 2)
print(p) # Point(x=1, y=2)
print(p == Point(1, 2)) # True@define is the modern API (attrs 20.1+). It creates a slotted class by default, so p.z = 3 raises AttributeError; pass slots=False if you need dynamic attributes.
Give an attribute a mutable default safelymutable-default-factory
from attrs import define, field, Factory
@define
class Bag:
items: list[int] = Factory(list)
# equivalent: items: list[int] = field(factory=list)
a, b = Bag(), Bag()
a.items.append(1)
print(b.items) # [] , not sharedA bare items: list = [] would share one list across all instances (attrs raises an error for known mutable defaults to save you). Factory(list) calls list() per instance.
Validate values on init and on assignmentvalidators
from attrs import define, field, validators
@define
class Server:
port: int = field(validator=[validators.instance_of(int),
validators.ge(1), validators.le(65535)])
s = Server(8080)
s.port = 70000 # raises ValueErrorWith @define, validators run on __init__ and again on every attribute assignment. With the classic @attr.s they only run at init unless you set on_setattr yourself.
Write a custom validator functioncustom-validator
from attrs import define, field
def _non_empty(instance, attribute, value):
if not value.strip():
raise ValueError(f"{attribute.name} must not be blank")
@define
class User:
name: str = field(validator=_non_empty)The signature is fixed: (instance, attribute, value). Raise to reject; the return value is ignored. The @name.validator decorator form does the same inline.
Convert values automaticallyconverters
from attrs import define, field
@define
class Config:
port: int = field(converter=int)
tags: tuple = field(converter=tuple, factory=tuple)
c = Config(port="8080", tags=["a", "b"])
print(c.port, c.tags) # 8080 ('a', 'b')Converters run before validators, on init and (with @define) on assignment. They receive only the value, so annotate the field with the converted type, not the input type.
Make an immutable classfrozen-class
from attrs import frozen, evolve
@frozen
class Coordinates:
lat: float
lon: float
home = Coordinates(52.5, 13.4)
nearby = evolve(home, lon=13.5)
# home.lat = 0 -> raises FrozenInstanceError@frozen is @define(frozen=True). Frozen instances are hashable by default and cost a bit extra at init; evolve() is the intended way to get modified copies.
Turn instances into dicts and tuplesasdict-serialization
from attrs import asdict, astuple, define
@define
class Point:
x: int
y: int
print(asdict(Point(1, 2))) # {'x': 1, 'y': 2}
print(astuple(Point(1, 2))) # (1, 2)
print(asdict(Point(1, 2), filter=lambda a, v: a.name != 'y'))asdict recurses into nested attrs classes, lists, and dicts. For real serialization work (JSON with unions, datetimes, renaming) use cattrs instead of hand-rolling on asdict.
Run logic after the generated initpost-init-hook
from attrs import define, field
@define
class Rectangle:
width: float
height: float
area: float = field(init=False)
def __attrs_post_init__(self):
self.area = self.width * self.heightfield(init=False) keeps derived values out of the constructor signature. On frozen classes, set derived fields inside __attrs_post_init__ via object.__setattr__.
Use underscore attributes with clean init namesprivate-attribute-alias
from attrs import define
@define
class Connection:
_socket: object
# init strips the underscore:
conn = Connection(socket=my_socket)Leading underscores are removed from the __init__ argument name automatically; pass alias=... to field() if you want a different public name.
Define attributes without type hintsno-type-annotations
from attrs import define, field
@define
class SomeClass:
a_number = field(default=42)
list_of_numbers = field(factory=list)Types are optional: assigning field() marks the attribute. Do not mix annotated and bare-field styles in one class; attrs requires one style per class.
Read the classic attr.s API in old codecompare-classic-api
import attr
@attr.s
class LegacyPoint:
x = attr.ib(default=0)
y = attr.ib(default=0)
# same machinery as:
# from attrs import define
# @define
# class Point: x: int = 0; ...@attr.s does not enable slots or setattr validators by default, unlike @define. Both APIs stay supported, but new code should use attrs.define per the project docs.
Control equality and ordering generationequality-and-ordering
from attrs import define, field
@define(order=True)
class Version:
major: int
minor: int
build_meta: str = field(eq=False, order=False, default="")
print(Version(1, 2) < Version(1, 3)) # True@define generates eq but not ordering unless order=True. Per-field eq=False excludes noisy fields (timestamps, metadata) from comparisons and hashing.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| pydantic | PyPI | Your data comes from outside (JSON, env, forms) and you want deep validation plus serialization built in, accepting the heavier runtime. |
| msgspec | PyPI | You want maximum speed for (de)serializing typed structs to JSON or MessagePack; its Struct classes are far faster but less flexible. |
| dataclasses | PyPI | You just want stdlib dataclasses on modern Python: import dataclasses with no install at all (this PyPI entry is only the old 3.6 backport). |
| cattrs | PyPI | You are already on attrs and need structuring and unstructuring to JSON-ready dicts; it is the companion, not a replacement. |