Fix: attrs Not Working — Slots Conflict, Validator Errors, and dataclasses Migration
Part of: Python Errors
Quick Answer
How to fix attrs errors — attrs.define vs attr.s API confusion, __slots__ inheritance issues, validator not running on assignment, converter type narrowing, cattrs structuring failed, and difference from dataclasses.
The Error
You import attrs and the API has changed since old tutorials:
import attr
@attr.s
class User:
name = attr.ib()
age = attr.ib()
# Works but emits DeprecationWarning in modern attrsOr __slots__ inheritance breaks:
import attrs
@attrs.define
class Base:
name: str
@attrs.define
class Child(Base):
age: int
extra_field = "default" # AttributeError: 'Child' object has no attribute 'extra_field'Or validators don’t fire on assignment after construction:
import attrs
@attrs.define
class User:
age: int = attrs.field(validator=attrs.validators.gt(0))
user = User(age=10)
user.age = -5 # No validation — should raise but doesn'tOr converters don’t narrow types as expected:
@attrs.define
class Item:
price: int = attrs.field(converter=int)
item = Item(price="abc") # ValueError: invalid literal for int()Or cattrs serialization fails on a nested attrs class:
import cattrs
data = cattrs.unstructure(some_attrs_obj)
restored = cattrs.structure(data, MyClass)
# Hangs or fails on complex nested typesattrs is the original “write classes with less boilerplate” library — predates the stdlib dataclasses (which it inspired). It’s still actively maintained and offers features dataclasses doesn’t have: validators, converters, more decorator options, optional __slots__. The modern API (@attrs.define) differs from the classic API (@attr.s / @attr.ib()) and most tutorials still use the old syntax. This guide covers the migration and modern usage.
Why This Happens
attrs originally used @attr.s (the “factory” decorator) with attr.ib() (attribute builder) — a unique syntax designed to work on Python 2 (no annotations). Python 3.6+ added type annotations and dataclasses appeared in 3.7. attrs responded with the modern @attrs.define API that uses annotations like dataclasses while keeping attrs’ superior validator/converter system.
The two APIs are otherwise compatible — @attrs.define is the modern surface, @attr.s is the classic surface. Code that mixes both works but is hard to read.
Fix 1: Modern API vs Classic API
# CLASSIC (still works, but old-school)
import attr
@attr.s
class User:
name = attr.ib()
age = attr.ib(default=0)
# MODERN (recommended)
import attrs
@attrs.define
class User:
name: str
age: int = 0Modern API key features:
@attrs.define— sane defaults (slots=True, weakref_slot=True, init=True, eq=True, hash=False)- Type annotations like dataclasses
attrs.field()for advanced field config (replacesattr.ib())attrs.frozenfor immutable classesattrs.mutableis an alias forattrs.define
Install:
pip install attrsField with advanced options:
import attrs
from attrs import field
@attrs.define
class User:
name: str
age: int = 0
email: str = field(validator=attrs.validators.matches_re(r".+@.+"))
tags: list[str] = field(factory=list) # Default factory
_private: int = field(default=0, alias="private") # Init arg name differs from attr nameCommon Mistake: Mixing @attr.s decorator with type annotations expecting them to define fields. The classic decorator ignores annotations:
# WRONG — annotations ignored with @attr.s
@attr.s
class User:
name: str # NOT a field
age = attr.ib() # IS a field
# CORRECT — either all classic or all modern
@attrs.define
class User:
name: str
age: intFix 2: Slots and Inheritance
@attrs.define enables __slots__ by default — fast attribute access, lower memory, but no dynamic attributes:
@attrs.define
class User:
name: str
user = User(name="Alice")
user.email = "[email protected]" # AttributeError: 'User' object has no attribute 'email'__slots__ requires every attribute to be declared in advance. If you need dynamic attributes:
@attrs.define(slots=False)
class User:
name: str
user = User(name="Alice")
user.email = "[email protected]" # Now worksInheritance and slots:
@attrs.define
class Base:
name: str
@attrs.define
class Child(Base):
age: int
child = Child(name="Alice", age=30)
print(child) # Child(name='Alice', age=30)This works correctly — attrs handles slot inheritance internally.
Common Mistake: Adding class-level attributes outside of fields:
@attrs.define
class User:
name: str
DEFAULT_ROLE = "user" # Class-level constant — NOT a fieldThis works (the constant is on the class, not the instance), but assigning to it on instances fails:
user.DEFAULT_ROLE = "admin" # AttributeError due to __slots__Either disable slots or use ClassVar:
from typing import ClassVar
@attrs.define
class User:
DEFAULT_ROLE: ClassVar[str] = "user" # Explicitly class-level
name: strattrs respects ClassVar — fields with that annotation aren’t instance attributes.
Fix 3: Validators
import attrs
from attrs.validators import gt, lt, ge, le, instance_of, in_, matches_re
@attrs.define
class User:
name: str = attrs.field(validator=instance_of(str))
age: int = attrs.field(validator=[gt(0), lt(150)])
role: str = attrs.field(validator=in_(["admin", "user", "guest"]))
email: str = attrs.field(validator=matches_re(r".+@.+"))Custom validators:
def positive_balance(instance, attribute, value):
if value < 0:
raise ValueError(f"{attribute.name} must be non-negative")
@attrs.define
class Account:
balance: float = attrs.field(validator=positive_balance)The signature is (instance, attribute, value) — attribute is the attrs Attribute metadata, useful for the error message.
Validators run only at construction by default:
@attrs.define
class User:
age: int = attrs.field(validator=attrs.validators.gt(0))
user = User(age=25) # Validates
user.age = -5 # Does NOT validate — direct assignment bypassesEnable on-set validation:
@attrs.define(on_setattr=attrs.setters.validate)
class User:
age: int = attrs.field(validator=attrs.validators.gt(0))
user = User(age=25)
user.age = -5 # ValueError: ...Pro Tip: Enable on_setattr=attrs.setters.validate on classes you mutate after construction. Pure dataclass-style “init and forget” classes don’t need it (validation happens once at construction). But mutable models — anything you assign to repeatedly during business logic — should validate on every set, not just once. Catches bugs at the actual point of failure.
Combine setters:
@attrs.define(on_setattr=[attrs.setters.convert, attrs.setters.validate])
class Item:
price: int = attrs.field(converter=int, validator=attrs.validators.gt(0))Both converters and validators run on every assignment.
Fix 4: Converters
Converters transform input values before assignment:
@attrs.define
class Item:
price: int = attrs.field(converter=int)
tags: list[str] = attrs.field(converter=list)
name: str = attrs.field(converter=str.strip)
item = Item(price="42", tags=("a", "b"), name=" hello ")
print(item.price) # 42 (int, not str)
print(item.tags) # ['a', 'b'] (list, not tuple)
print(item.name) # 'hello' (stripped)Optional converter (chain with default):
from attrs import field
from typing import Optional
def parse_int_or_none(value) -> Optional[int]:
if value is None or value == "":
return None
return int(value)
@attrs.define
class Config:
timeout: Optional[int] = field(default=None, converter=parse_int_or_none)Converters run AT construction, before validators. If you need conversion on assignment, enable on_setattr=attrs.setters.convert.
Common Mistake: Using a converter that raises and expecting attrs to handle the error nicely. If int("abc") raises ValueError inside a converter, it bubbles up unchanged — attrs doesn’t wrap it. For graceful handling, write a converter that catches and re-raises with context:
def safe_int(value):
try:
return int(value)
except (ValueError, TypeError) as e:
raise ValueError(f"Cannot convert {value!r} to int") from eFix 5: Frozen (Immutable) Classes
@attrs.frozen
class Point:
x: float
y: float
p = Point(x=1.0, y=2.0)
p.x = 3.0 # attrs.exceptions.FrozenInstanceErrorFrozen classes are immutable — attempting assignment raises. Useful for value objects, configuration, anything that should be hashable.
Frozen classes are automatically hashable:
points = {Point(1, 2), Point(3, 4), Point(1, 2)}
print(len(points)) # 2 (Point(1,2) is deduplicated)Create modified copies with evolve:
import attrs
p1 = Point(1, 2)
p2 = attrs.evolve(p1, x=10)
print(p1) # Point(x=1, y=2)
print(p2) # Point(x=10, y=2)evolve is the immutable equivalent of mutation — returns a new instance with the specified fields changed.
For pattern-matching style immutable data, frozen attrs classes pair well with structural pattern matching (Python 3.10+):
@attrs.frozen
class Circle:
radius: float
@attrs.frozen
class Square:
side: float
def area(shape):
match shape:
case Circle(radius=r):
return 3.14 * r ** 2
case Square(side=s):
return s * sFix 6: Serialization with cattrs
attrs alone provides classes; cattrs handles serialization to/from dicts:
pip install cattrsimport attrs
import cattrs
@attrs.define
class User:
name: str
age: int
tags: list[str]
user = User(name="Alice", age=30, tags=["admin", "active"])
# To dict
data = cattrs.unstructure(user)
# {'name': 'Alice', 'age': 30, 'tags': ['admin', 'active']}
# From dict
restored = cattrs.structure(data, User)Nested attrs classes:
@attrs.define
class Address:
street: str
city: str
@attrs.define
class Person:
name: str
address: Address
person = Person(name="Alice", address=Address(street="123 Main", city="NYC"))
data = cattrs.unstructure(person)
# {'name': 'Alice', 'address': {'street': '123 Main', 'city': 'NYC'}}
restored = cattrs.structure(data, Person)
# Person(name='Alice', address=Address(street='123 Main', city='NYC'))Custom converters with cattrs:
from datetime import datetime
import cattrs
converter = cattrs.GenConverter()
converter.register_structure_hook(
datetime,
lambda value, _: datetime.fromisoformat(value),
)
converter.register_unstructure_hook(
datetime,
lambda dt: dt.isoformat(),
)
@attrs.define
class Event:
when: datetime
event = Event(when=datetime.now())
data = converter.unstructure(event)
# {'when': '2025-04-24T10:00:00.123456'}
restored = converter.structure(data, Event)Common Mistake: Using stdlib dataclasses.asdict() on attrs classes — works for simple cases but doesn’t handle the full feature set (frozen, slots, validators with custom errors). Use cattrs.unstructure() and cattrs.structure() for consistency across nested structures.
Fix 7: attrs vs dataclasses
# dataclasses (stdlib, Python 3.7+)
from dataclasses import dataclass
@dataclass
class User:
name: str
age: int = 0
# attrs (third-party)
import attrs
@attrs.define
class User:
name: str
age: int = 0Feature comparison:
| Feature | dataclasses | attrs |
|---|---|---|
| In stdlib | Yes | No |
| Validators | No | Yes |
| Converters | No | Yes |
| Slots by default | No (use slots=True in 3.10+) | Yes |
| Frozen | Yes | Yes |
__slots__ inheritance handling | Manual | Automatic |
on_setattr hooks | No | Yes |
| Backwards compat to 3.6 | No (3.7+) | Yes |
| Performance | Slightly faster | Similar |
When to use which:
- dataclasses: Simple data containers, no validation needs, prefer stdlib
- attrs: Need validators/converters/on_setattr, library author requiring older Python, want best-in-class API
For data validation that goes beyond what attrs provides, Pydantic is the heavier alternative — runtime validation with serialization, JSON Schema generation, network type support. attrs is faster and lighter when you don’t need the validation depth.
For Pydantic patterns that overlap with attrs, see Pydantic validation error.
Fix 8: Performance and Memory
attrs with slots is extremely fast — competitive with hand-written __slots__ classes:
@attrs.define # slots=True by default
class Item:
name: str
price: intWithout slots, attrs has overhead:
@attrs.define(slots=False)
class Item:
name: str
price: intDisabling slots adds a __dict__ per instance — bigger memory footprint, slower attribute access. Only disable when you need dynamic attributes.
Hash performance — frozen classes auto-generate __hash__:
@attrs.frozen
class Point:
x: float
y: float
# Cached hash is fast for repeated lookups in sets/dictsComparison performance — attrs uses tuple comparison internally:
@attrs.define(eq=True, order=True)
class Item:
priority: int
name: str
items = [Item(2, "b"), Item(1, "a"), Item(3, "c")]
items.sort()
# Sorted by (priority, name) tuple comparisonPro Tip: For large object graphs where memory matters, use @attrs.frozen with slots. The combination gives you Python’s fastest object representation: immutable, hashable, fixed-size, no per-instance dict. Reduces memory by 50-80% vs equivalent regular classes for large collections.
Still Not Working?
attrs vs Pydantic vs msgspec
- attrs — Lightweight class builder with validators. Best for internal classes that need validation but not full serialization.
- Pydantic — Heavyweight validation + serialization + JSON Schema. Best for API boundaries. See Pydantic validation error.
- msgspec — Fastest serialization library, similar API to attrs. Best for high-throughput parsing/serialization.
For internal data structures, attrs is the right balance. For external APIs / network boundaries, Pydantic. For performance-critical serialization, msgspec.
Integration with Type Checkers
attrs has dedicated mypy support:
# pyproject.toml
[tool.mypy]
plugins = ["mypy_drf_plugin.main"] # ...wait, wrong plugin
plugins = [] # attrs has built-in stubs since attrs 21.3+attrs ships with attrs.pyi stub files — mypy understands @attrs.define natively. For pyright/Pylance, same — full support without configuration.
For mypy-specific patterns with attrs classes, see Python mypy type error.
Builder Pattern
For complex construction with many optional fields, use the builder pattern:
@attrs.define
class HttpRequest:
url: str
method: str = "GET"
headers: dict[str, str] = attrs.field(factory=dict)
timeout: int = 30
retries: int = 0
@attrs.define
class HttpRequestBuilder:
_request: HttpRequest = attrs.field(factory=lambda: HttpRequest(url=""))
def url(self, value: str) -> "HttpRequestBuilder":
self._request = attrs.evolve(self._request, url=value)
return self
def method(self, value: str) -> "HttpRequestBuilder":
self._request = attrs.evolve(self._request, method=value)
return self
def build(self) -> HttpRequest:
return self._request
req = HttpRequestBuilder().url("https://api.example.com").method("POST").build()attrs.evolve makes builders clean for immutable types.
Testing attrs Classes
import attrs
import pytest
@attrs.define
class User:
name: str = attrs.field(validator=attrs.validators.matches_re(r"\w+"))
def test_user_creation():
user = User(name="Alice")
assert user.name == "Alice"
def test_user_validation():
with pytest.raises(ValueError):
User(name="invalid!@#") # Special charsFor pytest fixture patterns with attrs classes, see pytest fixture not found.
Combining with FastAPI
FastAPI uses Pydantic by default but can accept attrs classes via cattrs adapters or by wrapping them in Pydantic models. For pure attrs in FastAPI, you typically convert to a Pydantic model at the API boundary:
import attrs
from pydantic import BaseModel
@attrs.define
class InternalUser:
name: str
age: int
class UserResponse(BaseModel):
name: str
age: int
@app.get("/users/{user_id}", response_model=UserResponse)
async def get_user(user_id: int):
internal = await get_user_from_db(user_id) # Returns InternalUser
return UserResponse(name=internal.name, age=internal.age)For FastAPI dependency patterns, see FastAPI dependency injection error.
Production Incident: Serialization Drift After attrs Upgrade
attrs is “just” a class builder, so teams treat library bumps as low-risk. The bug surfaces hours or days later as a Slack message from a downstream team: “your API started returning extra_field and our parser is rejecting it.” The blast radius is every API consumer that does strict schema validation — mobile clients with code-generated models, partner integrations, anything calling additionalProperties: false.
Where drift creeps in:
- New attrs minor versions occasionally tighten how
cattrs.unstructurewalks_privateunderscore fields — fields previously hidden start appearing in serialized output - Switching from
@attr.sto@attrs.defineflipsslots=True, which changes howcattrsdiscovers attributes — order of keys in the JSON output changes too, breaking any consumer that relies on field order - A
field(default=NOTHING)becomesfield()and now serializes asnullinstead of being omitted
Symptoms from the SRE side:
- Sudden uptick in 422 / 400 responses from downstream services after a deploy
- A serialization snapshot test that passed in CI fails locally with a new key
- Schema registry (Buf, Confluent) flags a “compatibility break” for the next release
Diagnose before rolling back:
# Compare wire output across versions
import cattrs, json
snapshot = json.dumps(cattrs.unstructure(sample_user), sort_keys=True)
# Commit this snapshot to the repo; CI diff catches drift before deployContainment:
- Pin
attrsandcattrsinrequirements.txtwith exact versions, not>= - Add a contract test per endpoint: serialize a fixture object and assert the exact JSON matches a checked-in golden file
- For external APIs, freeze the wire schema by writing an explicit
cattrsconverter withregister_unstructure_hook(User, ...)— never rely on the default
Rollback path: revert the attrs bump first, then investigate which field changed. Never patch the schema downstream without telling consumers — that just spreads the incident.
Hash Collisions in Sets of Frozen attrs
@attrs.frozen auto-generates __hash__ from every field. If you add a mutable list or dict default to a frozen class, hashing throws TypeError: unhashable type only when the set is first probed — often deep inside production code:
@attrs.frozen
class Cache:
key: str
tags: list[str] = attrs.field(factory=list)
s = {Cache(key="a")} # TypeError: unhashable type: 'list'Convert mutable defaults to tuple or frozenset before storage, or exclude them from hashing with hash=False on the field.
__init_subclass__ Stops Firing Under slots
Custom __init_subclass__ hooks (used by plugin registries, ORM-style discovery) break under @attrs.define’s default slots because attrs rebuilds the class. Either set slots=False, or move the registration to a decorator instead of a base-class hook.
Forward Refs Break After Module Reload
In notebooks or auto-reloading dev servers, attrs caches resolved type hints. Reloading a module that defines a forward-referenced attrs class can leave the cache pointing at the old type, and cattrs.structure quietly returns objects of the previous class. Restart the kernel or call attrs.resolve_types(cls, attrs.fields(cls)) after reload.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Marshmallow Not Working — Schema Errors, Load vs Dump, and Field Validation
How to fix Marshmallow errors — Schema not validated on dump, ValidationError messages format, unknown field handling, missing vs default, post_load object construction, and Marshmallow 3 to 4 migration.
Fix: joblib Not Working — Parallel Backends, Memory Cache, and Pickling Errors
How to fix joblib errors — Parallel n_jobs slower than expected, Memory cache miss, backend loky vs threading vs multiprocessing, pickling lambda not supported, dump load file size, and pytest interference.
Fix: Pipenv Not Working — Lock File Generation, Shell Activation, and Dependency Resolution
How to fix Pipenv errors — pipenv lock takes forever, Pipfile.lock not generated, shell activation broken, no virtualenv created, dependency conflict, and migration to uv or Poetry.
Fix: Copier Not Working — Template Updates, Question Conditions, and Migrations
How to fix Copier errors — copier.yml not found, conditional questions not appearing, update breaks generated project, migrations between versions, Jinja vs YAML escaping, and answers file conflict.