Fix: attrs Not Working — Slots Conflict, Validator Errors, and dataclasses Migration
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.
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.