Intermediate

You have a class that holds data — a User, a Product, an APIResponse. Without attrs, you write __init__, __repr__, __eq__, add type validation manually, and somehow end up with forty lines of boilerplate before a single line of real logic. The attrs library fixes all of this with a single decorator and a handful of field definitions.

The attrs library (installed as attr or the newer attrs package) generates everything you need automatically: constructors, comparison methods, string representations, and more. It also gives you built-in validators and converters that run at assignment time, which pure Python dataclasses do not provide out of the box. You install it with pip install attrs.

In this article, you will learn how to define attrs classes with the @define and @attrs decorators, how to add validators and converters to fields, how to use frozen classes for immutability, how slots work for memory efficiency, and how to compare attrs to Python’s built-in dataclasses. A real-life example at the end builds a validated configuration loader using attrs classes.

Quick Example: attrs in 30 Seconds

Here is the minimal attrs pattern — a data class defined in four lines with auto-generated __init__, __repr__, and __eq__:

# quick_attrs.py
import attr

@attr.s(auto_attribs=True)
class Point:
    x: float
    y: float

p1 = Point(1.0, 2.0)
p2 = Point(1.0, 2.0)
print(p1)
print(p1 == p2)
print(p1.x)

Output:

Point(x=1.0, y=2.0)
True
1.0

The @attr.s(auto_attribs=True) decorator reads the class-level type annotations and turns them into attrs fields automatically. Python generates __init__, __repr__, and __eq__ for you. With the newer API (attrs package >= 20.1.0), you can use @attr.define instead, which is the recommended approach for new code.

The sections below cover validators, converters, defaults, slots, frozen classes, and the differences between the old @attr.s API and the modern @attr.define API.

What Is attrs and Why Use It?

The attrs library is a Python package that takes the tedium out of writing data classes. When you decorate a class with @attr.define or @attr.s, it inspects the class body, identifies the fields you have declared, and automatically generates all the standard dunder methods your class needs.

Python 3.7 introduced dataclasses as a stdlib alternative, but attrs predates it and offers features that dataclasses still lack: built-in validators, converters, and __slots__ support without extra boilerplate. Many production libraries — including Hypothesis and cattrs — are built on attrs.

Featureattrsdataclassesplain class
Auto __init__YesYesNo
Auto __repr__YesYesNo
Auto __eq__YesYesNo
Built-in validatorsYesNoNo
Built-in convertersYesNoNo
__slots__ supportYes (one kwarg)VerboseManual
Frozen (immutable)YesYesManual
Third-partyYes (pip install)No (stdlib)No

The choice comes down to your project’s needs. For simple value objects, dataclasses is fine and avoids a dependency. When you need runtime validation, type coercion, or slots for large object collections, attrs is the better tool.

Forty lines of boilerplate. One decorator. The math is straightforward.
Forty lines of boilerplate. One decorator. The math is straightforward.

Defining Classes with @attr.define

The modern attrs API uses @attr.define (or equivalently @attrs.define from the attrs package). This is the recommended approach for all new code. It enables __slots__, disables hash generation by default (since mutable objects should not be hashed), and uses the field() function for advanced configuration.

# define_class.py
import attr

@attr.define
class User:
    name: str
    email: str
    age: int = 0

u = User(name="Alice", email="alice@example.com", age=30)
print(u)
print(repr(u))

Output:

User(name='Alice', email='alice@example.com', age=30)
User(name='Alice', email='alice@example.com', age=30)

Fields without a default must come before fields with defaults, just like function parameters. The age=0 default means you can construct a User without providing an age. The @attr.define decorator uses slots internally by default, which is why you cannot add arbitrary attributes after construction — only declared fields are allowed.

Using attr.field() for Advanced Field Options

When a simple annotation is not enough, attr.field() gives you full control over a field’s behavior. You can set the default, mark a field as not included in __repr__, exclude it from comparisons, or add a factory default for mutable defaults:

# advanced_fields.py
import attr
from typing import List

@attr.define
class Team:
    name: str
    members: List[str] = attr.Factory(list)
    _internal_id: int = attr.field(default=0, repr=False, alias="_internal_id")

t = Team(name="Backend")
t.members.append("Alice")
t.members.append("Bob")
print(t)
print(t.members)

Output:

Team(name='Backend', members=['Alice', 'Bob'])
['Alice', 'Bob']

Notice attr.Factory(list) — you must use this instead of default=[] for mutable defaults. Using a bare list as a default would share the same list across all instances, which is a classic Python gotcha. The repr=False on _internal_id keeps internal fields out of the printed representation.

Adding Validators to Fields

Validators are the killer feature that separates attrs from plain dataclasses. A validator is a function that runs at construction time (and optionally at assignment time) and raises an exception if the value is invalid. You attach a validator to a field with attr.field(validator=...) or the @field_name.validator decorator.

# validators.py
import attr

@attr.define
class Product:
    name: str = attr.field()
    price: float = attr.field()
    quantity: int = attr.field()

    @name.validator
    def _validate_name(self, attribute, value):
        if not value or len(value.strip()) == 0:
            raise ValueError(f"{attribute.name} cannot be empty")

    @price.validator
    def _validate_price(self, attribute, value):
        if value < 0:
            raise ValueError(f"{attribute.name} must be non-negative, got {value}")

    @quantity.validator
    def _validate_quantity(self, attribute, value):
        if not isinstance(value, int) or value < 0:
            raise ValueError(f"{attribute.name} must be a non-negative integer")

# Valid usage
p = Product(name="Widget", price=9.99, quantity=100)
print(p)

# Invalid usage -- triggers validator
try:
    bad = Product(name="", price=9.99, quantity=5)
except ValueError as e:
    print(f"Error: {e}")

try:
    bad2 = Product(name="Widget", price=-5.0, quantity=5)
except ValueError as e:
    print(f"Error: {e}")

Output:

Product(name='Widget', price=9.99, quantity=100)
Error: name cannot be empty
Error: price must be non-negative, got -5.0

The validator method receives three arguments: self (the instance being created), attribute (an Attribute object with metadata like attribute.name), and value (the value being assigned). Raise any exception you like -- ValueError and TypeError are the most common choices. attrs also ships several built-in validators in attr.validators: instance_of(), in_(), matches_re(), and more.

Validators run before the object exists. Bad data never makes it in.
Validators run before the object exists. Bad data never makes it in.

Using Built-In Validators

Writing custom validator functions every time gets repetitive for common cases. The attr.validators module ships a set of composable validators you can combine with attr.validators.and_():

# builtin_validators.py
import attr

@attr.define
class Config:
    host: str = attr.field(validator=attr.validators.instance_of(str))
    port: int = attr.field(
        validator=[
            attr.validators.instance_of(int),
            attr.validators.in_(range(1, 65536))
        ]
    )
    log_level: str = attr.field(
        validator=attr.validators.in_(["DEBUG", "INFO", "WARNING", "ERROR"])
    )

c = Config(host="localhost", port=8080, log_level="INFO")
print(c)

try:
    bad = Config(host="localhost", port=99999, log_level="INFO")
except ValueError as e:
    print(f"Error: {e}")

Output:

Config(host='localhost', port=8080, log_level='INFO')
Error: ("'port' must be in [range(1, 65536)] (got 99999 that is a ).", ...)

Passing a list to the validator argument runs all validators in sequence -- all must pass. attr.validators.in_ works with any container: lists, sets, ranges, enums. This composability makes it easy to express complex constraints without writing boilerplate.

Using Converters to Coerce Input Types

A converter is a function that transforms the value before it is stored on the instance. Unlike validators (which reject bad input), converters reshape input into the form you need. This is invaluable when your class receives data from JSON, config files, or web APIs where types are not guaranteed.

# converters.py
import attr

@attr.define
class Measurement:
    value: float = attr.field(converter=float)
    unit: str = attr.field(converter=str.lower)
    label: str = attr.field(converter=str.strip)

# Integers are silently converted to floats
m = Measurement(value="42", unit="KG", label="  body weight  ")
print(m)
print(type(m.value))

Output:

Measurement(value=42.0, unit='kg', label='body weight')

The converter runs before validators, so you can combine both: convert the raw input first, then validate the clean result. In the example above, "42" (a string) is converted to 42.0 (a float), and the unit is normalized to lowercase. This makes your class robust to the messiness of real-world input without cluttering your business logic with type-coercion code.

Converters run first. By the time the validator sees the value, it is already the right type.
Converters run first. By the time the validator sees the value, it is already the right type.

Frozen Classes and __slots__

Two optimizations in attrs that you get almost for free: frozen=True makes instances immutable (useful for dictionary keys and thread safety), and slots (enabled by default in @attr.define) dramatically reduce memory usage for classes with many instances.

# frozen_slots.py
import attr

@attr.define(frozen=True)
class Color:
    red: int = attr.field(validator=attr.validators.in_(range(256)))
    green: int = attr.field(validator=attr.validators.in_(range(256)))
    blue: int = attr.field(validator=attr.validators.in_(range(256)))

    def as_hex(self):
        return f"#{self.red:02x}{self.green:02x}{self.blue:02x}"

white = Color(255, 255, 255)
print(white)
print(white.as_hex())

# Frozen -- mutation raises FrozenInstanceError
try:
    white.red = 0
except attr.exceptions.FrozenInstanceError as e:
    print(f"Error: {e}")

# Can be used as a dictionary key because it is hashable
palette = {white: "background"}
print(palette[Color(255, 255, 255)])

Output:

Color(red=255, green=255, blue=255)
#ffffff
Error: can't set attribute
background

Frozen attrs classes auto-generate __hash__, making them suitable as dict keys or set members. The __slots__ mechanism (used by @attr.define by default) tells Python to store instance attributes in a fixed array rather than a per-instance __dict__, reducing memory usage by roughly 30--40% for classes with many instances. This is meaningful when you're creating thousands of objects (e.g., parsing large datasets).

Evolving Instances with attr.evolve()

Frozen instances cannot be mutated, but you often need a modified copy. attr.evolve() creates a new instance with selected fields changed, leaving all other fields identical to the original. It is attrs' equivalent of dataclasses' replace().

# evolve.py
import attr

@attr.define(frozen=True)
class ServerConfig:
    host: str
    port: int
    debug: bool = False

prod = ServerConfig(host="prod.example.com", port=443, debug=False)
dev = attr.evolve(prod, host="localhost", port=8080, debug=True)

print(prod)
print(dev)
print(prod is dev)  # Different objects

Output:

ServerConfig(host='prod.example.com', port=443, debug=False)
ServerConfig(host='localhost', port=8080, debug=True)
False

This pattern is extremely useful in functional-style code where you want immutable data structures but need to derive updated versions of them. It also ensures validators re-run on the evolved fields, so you cannot accidentally create an invalid instance through evolve.

Frozen doesn't mean final. It means you need a new copy to make changes.
Frozen doesn't mean final. It means you need a new copy to make changes.

Real-Life Example: Validated Configuration Loader

Here is a practical example that ties together attrs classes, validators, converters, and attr.evolve() to build a configuration loader that reads settings from a dictionary (as you would get from a JSON or YAML file) and validates them on load:

# config_loader.py
import attr
import json
from typing import Optional

@attr.define(frozen=True)
class DatabaseConfig:
    host: str = attr.field(converter=str.strip)
    port: int = attr.field(
        converter=int,
        validator=[attr.validators.instance_of(int),
                   attr.validators.in_(range(1, 65536))]
    )
    name: str = attr.field(converter=str.strip)
    pool_size: int = attr.field(
        default=5,
        converter=int,
        validator=attr.validators.in_(range(1, 101))
    )

    @host.validator
    def _check_host(self, attribute, value):
        if not value:
            raise ValueError("Database host cannot be empty")

@attr.define(frozen=True)
class AppConfig:
    database: DatabaseConfig
    debug: bool = attr.field(converter=bool, default=False)
    log_level: str = attr.field(
        default="INFO",
        validator=attr.validators.in_(["DEBUG", "INFO", "WARNING", "ERROR"])
    )

def load_config(raw: dict) -> AppConfig:
    db_raw = raw.get("database", {})
    db = DatabaseConfig(
        host=db_raw.get("host", ""),
        port=db_raw.get("port", 5432),
        name=db_raw.get("name", ""),
        pool_size=db_raw.get("pool_size", 5),
    )
    return AppConfig(
        database=db,
        debug=raw.get("debug", False),
        log_level=raw.get("log_level", "INFO"),
    )

# Simulate loading from a JSON file
json_input = '''
{
    "database": {"host": "  db.prod.example.com  ", "port": "5432", "name": "myapp"},
    "debug": false,
    "log_level": "WARNING"
}
'''
config = load_config(json.loads(json_input))
print(config)
print(config.database.host)  # Stripped by converter

# Create a dev variant
dev_config = attr.evolve(
    config,
    debug=True,
    log_level="DEBUG",
    database=attr.evolve(config.database, host="localhost", port=5432)
)
print(dev_config)

# Bad config raises immediately
try:
    bad = load_config({"database": {"host": "", "port": 5432, "name": "myapp"}})
except ValueError as e:
    print(f"Config error: {e}")

Output:

AppConfig(database=DatabaseConfig(host='db.prod.example.com', port=5432, name='myapp', pool_size=5), debug=False, log_level='WARNING')
db.prod.example.com
AppConfig(database=DatabaseConfig(host='localhost', port=5432, name='myapp', pool_size=5), debug=True, log_level='DEBUG')
Config error: Database host cannot be empty

This pattern -- loading raw dicts into validated attrs classes -- is the core of any robust configuration layer. Bad configuration fails immediately at startup rather than causing mysterious runtime errors later. You can extend this by adding a from_file() classmethod that reads JSON or YAML and calls load_config(), or by adding environment variable overrides with attr.evolve().

Frequently Asked Questions

Should I use attrs or dataclasses?

Use dataclasses if you want zero additional dependencies and only need auto-generated __init__, __repr__, and __eq__. Use attrs when you need built-in validators, converters, or memory-efficient slots without extra boilerplate. attrs also predates dataclasses and has a more mature ecosystem (cattrs for serialization, Hypothesis integration, etc.).

What is the difference between @attr.s and @attr.define?

@attr.s (or @attr.attrs) is the legacy API. @attr.define is the modern API introduced in attrs 20.1.0 and is recommended for all new code. Key differences: @attr.define enables __slots__ by default, disables hash generation for mutable classes, and uses a cleaner decorator signature. The attr.ib() function is the legacy equivalent of attr.field().

Do validators run when I set an attribute after construction?

With @attr.define (which uses slots and does not enable on_setattr by default), validators run at construction time only. To enable validation on every assignment, pass on_setattr=attr.setters.validate to the field: attr.field(on_setattr=attr.setters.validate). For fully immutable classes, use frozen=True -- mutation is then impossible, so the question is moot.

How do I serialize an attrs class to JSON?

Use attr.asdict(instance) to convert an attrs instance to a plain dictionary, then pass that to json.dumps(). For the reverse (dict to attrs), use MyClass(**data_dict) or the cattrs library for more complex type conversions. cattrs (companion library, separate package) handles nested attrs classes, lists, and optional fields automatically.

Does attrs support inheritance?

Yes, attrs classes can inherit from each other. Subclass fields are appended after the parent's fields in the generated __init__. However, mixing @attr.define (slots-based) with non-slots parent classes can cause issues. For clean inheritance, use @attr.define consistently throughout the class hierarchy or pass slots=False if you need to inherit from a plain class.

How do I handle Optional fields with attrs?

Declare the field type as Optional[str] and set the default to None: name: Optional[str] = attr.field(default=None). If you want to validate that it is either None or a string, use attr.validators.optional(attr.validators.instance_of(str)) -- the optional() wrapper short-circuits when the value is None.

Conclusion

The attrs library eliminates the boilerplate that comes with writing data classes in Python while adding features that the stdlib's dataclasses module does not offer. You have seen how to define classes with @attr.define, configure fields with attr.field(), enforce constraints with validators (@field.validator, attr.validators.instance_of(), attr.validators.in_()), coerce types with converters, create immutable instances with frozen=True, and derive modified copies with attr.evolve().

To go further, try extending the configuration loader example to read from environment variables or YAML files, and use cattrs to handle serialization and deserialization of nested attrs objects. The real power of attrs shows up in large codebases where hundreds of small data classes would otherwise each require their own boilerplate methods.

Official documentation: attrs documentation at attrs.org. The cattrs library is worth exploring as a companion for serialization.