June 17, 2025
Python OOP Dataclasses

Python Dataclasses, __slots__, and Frozen Classes

You're writing classes, and it's working fine. You've got your __init__, your attributes, your methods. But you keep writing the same boilerplate over and over: initializing fields, implementing __repr__ so you can actually debug things, adding __eq__ so you can compare instances, setting up default values. It's repetitive. Tedious. Error-prone. Someone at PyCon looked at this problem and thought: what if the class just did that for you?

That's the story of dataclasses. They're a shortcut for writing classes that primarily exist to hold structured data. But they're not magic, and they come with tradeoffs. Add in __slots__ for memory efficiency and frozen=True for immutability, and you've got three powerful features that solve real problems in modern Python.

In this article, we're going to cover all three tools in depth. We'll start with why these features were introduced in the first place, what specific pain they address, and then walk through every practical variation you'll encounter in real codebases. By the end, you'll know not just how to use dataclasses, but when to use them, when to avoid them, and how to combine them with __slots__ and frozen=True for production-grade code. Whether you're parsing API responses, modeling configuration objects, or building data pipelines for AI/ML work, these patterns come up constantly. Let's dig in.

Table of Contents
  1. The Problem: Boilerplate City
  2. Why Dataclasses Replaced **init**
  3. Dataclasses: Automation to the Rescue
  4. Default Values and field()
  5. Frozen Classes for Safety
  6. **post_init**: Custom Logic After Initialization
  7. **slots** Memory Benefits
  8. Dataclasses + **slots** (Python 3.10+)
  9. Inheritance with Dataclasses
  10. Common Dataclass Mistakes
  11. Dataclasses vs. NamedTuple vs. Regular Classes
  12. Practical Example: Configuration Management
  13. Advanced: The Order Matters
  14. Dataclass Comparison and Hashing
  15. Dataclasses with Validators
  16. Field Metadata and Custom Behavior
  17. Dataclasses and Type Hints
  18. Slots and Performance Impact
  19. Copying and Replacing Fields
  20. Dataclasses in Action: Real-World API Response Parsing
  21. When NOT to Use Dataclasses
  22. Putting It All Together

The Problem: Boilerplate City

Before dataclasses landed in Python 3.7, writing a simple data-holding class meant a lot of ceremony. If you wanted a class that bundled a few fields together and behaved sensibly when compared or printed, you had to write every single method by hand. This wasn't a design flaw so much as a reflection of Python's general-purpose class system, it doesn't assume you want comparison or a readable string representation. You get to define what those mean. The cost of that freedom is that you write a lot of code that looks almost identical across every project.

python
class Person:
    def __init__(self, name, age, email):
        self.name = name
        self.age = age
        self.email = email
 
    def __repr__(self):
        return f"Person(name={self.name!r}, age={self.age!r}, email={self.email!r})"
 
    def __eq__(self, other):
        if not isinstance(other, Person):
            return NotImplemented
        return self.name == other.name and self.age == other.age and self.email == other.email
 
    def __hash__(self):
        return hash((self.name, self.age, self.email))
 
p1 = Person("Alice", 30, "alice@example.com")
print(p1)  # Person(name='Alice', age=30, email='alice@example.com')

This works, but it's verbose. You're writing five methods to accomplish one task: bundling three fields together. The __init__ is mechanical. The __repr__ is copy-paste logic. The __eq__ is boilerplate, and you need it because otherwise Person("Alice", 30, "alice@example.com") == Person("Alice", 30, "alice@example.com") returns False (they're different objects). Notice that if you add a fourth field, you have to update __init__, __repr__, __eq__, and __hash__ separately. Miss one and you get a subtle bug that only shows up in edge cases.

And you're repeating this pattern in every file. Every project. Every time you need to group related data.

The hidden layer: Why is this so verbose? Because Python classes are general. They're blueprints for everything from simple data containers to complex objects with dozens of methods and state. When you're writing a simple data container, all that generality becomes noise.

Why Dataclasses Replaced init

The question isn't just "how do dataclasses work?" The real question is "why did Python need them in the first place?" Understanding the motivation makes the feature stick.

Before dataclasses, experienced Python developers reached for a few different solutions. Some used collections.namedtuple, which gave you immutable, lightweight containers with named fields. It was compact but awkward, you couldn't add methods cleanly, you couldn't have mutable fields, and the syntax looked strange. Others used attrs, a third-party library that introduced the decorator-based approach that dataclasses would eventually borrow. attrs was powerful but required an external dependency. Some teams just accepted the boilerplate and wrote it out every time.

The real cost wasn't the typing. It was the maintenance surface. Every time you added a field to a class, you had to remember to update __init__, __repr__, and __eq__. Forget one and you'd get tests passing but bugs lurking. The __eq__ method in particular is easy to let fall out of sync, and when it does, you get objects that look equal in print but aren't equal in comparisons, or vice versa. In AI/ML work, where you're often comparing model configurations, hyperparameters, or data records, that inconsistency can corrupt results in ways that are hard to trace.

PEP 557, introduced in Python 3.7, formalized what attrs had proven in practice: if your class is primarily a data container, you should declare what fields it has, and Python should handle the rest. The @dataclass decorator reads your field annotations at class creation time and generates the boilerplate automatically. The key insight is that the generated code is identical to what you'd write by hand, it's not slower, not less correct, not magical. It's just automated. You trade explicit boilerplate for a single declarative annotation, and you get a class that stays consistent as you add or rename fields because the decorator regenerates everything from the single source of truth: your field declarations.

Dataclasses: Automation to the Rescue

The @dataclass decorator fixes this. It introspects your class definition and auto-generates __init__, __repr__, __eq__, and a few others for you. The transformation happens at import time, not at runtime, so there's no performance penalty for the generated code itself.

python
from dataclasses import dataclass
 
@dataclass
class Person:
    name: str
    age: int
    email: str
 
p1 = Person("Alice", 30, "alice@example.com")
p2 = Person("Alice", 30, "alice@example.com")
 
print(p1)  # Person(name='Alice', age=30, email='alice@example.com')
print(p1 == p2)  # True

What just happened: You wrote field annotations (like name: str). The @dataclass decorator read those annotations, generated __init__ that accepts those parameters in order, generated __repr__ that shows all fields, and generated __eq__ that compares all fields. The instances are equal if their fields are equal, that's the power of dataclasses. Now if you add a fourth field, you add one line to the class definition and every generated method updates automatically.

Notice: no custom methods. No boilerplate. Just the data you care about, and the decorator handles the plumbing. This isn't just a convenience, it's a correctness guarantee. The __eq__ will always match the fields in __repr__ because both are generated from the same source.

Default Values and field()

Real data structures often have optional fields or fields with defaults. That's where things get interesting, and where a classic Python gotcha is waiting to bite you if you're not paying attention.

python
from dataclasses import dataclass, field
 
@dataclass
class Person:
    name: str
    age: int = 0
    email: str = ""
    tags: list = field(default_factory=list)
 
p1 = Person("Alice")
p2 = Person("Bob", age=25, tags=["developer", "python"])
 
print(p1)  # Person(name='Alice', age=0, email='', tags=[])
print(p2)  # Person(name='Bob', age=25, email='', tags=['developer', 'python'])

Here's the critical bit: notice tags: list = field(default_factory=list) instead of tags: list = []. That distinction matters enormously, and getting it wrong leads to one of the most common Python bugs in codebases that haven't had a careful review.

Why this matters: If you wrote tags: list = [], you'd create a single empty list that gets shared across all instances that don't override it. Every Person without explicit tags would share the same list object. That's a bug waiting to happen:

python
@dataclass
class BadPerson:
    name: str
    tags: list = []  # DON'T DO THIS
 
p1 = BadPerson("Alice")
p2 = BadPerson("Bob")
 
p1.tags.append("admin")
print(p2.tags)  # ['admin']  <- Bob's tags got polluted!

The good news is that the @dataclass decorator actually catches this at class definition time and raises a ValueError with a helpful message: "mutable default is not allowed." It won't let you shoot yourself in the foot with a bare mutable default. But it doesn't catch this in regular __init__ methods, which is one more reason to prefer dataclasses for simple data containers.

The hidden layer: This is a classic Python gotcha. Default argument values are evaluated once when the function is defined, not each time the function is called. So mutable defaults are shared across all calls. field(default_factory=list) solves this by calling list() fresh for each instance.

Other field() parameters you'll use:

python
from dataclasses import dataclass, field
import time
 
@dataclass
class Config:
    name: str
    debug: bool = field(default=False)
    secret_key: str = field(default="", repr=False)  # Don't show in __repr__
    internal_id: int = field(default=0, init=False)  # Not part of __init__
    timestamp: float = field(default_factory=time.time)
 
c = Config(name="production")
print(c)  # Config(name='production', debug=False)  <- secret_key is hidden

The repr=False option is particularly useful in production systems where you're logging objects. The last thing you want is a password or API key showing up in your application logs because someone printed a config object. You get the readable representation for everything else while sensitive fields stay hidden.

Key field() options:

  • default: A static default value
  • default_factory: A callable that returns the default (for mutable types)
  • repr=False: Exclude from the __repr__ output (useful for secrets)
  • init=False: Don't include in __init__ (useful for computed fields)
  • compare=False: Exclude from __eq__ comparisons
  • hash=False: Exclude from __hash__ computation

Frozen Classes for Safety

Immutability is one of those concepts that sounds abstract until the first time a bug bites you because something mutated state it shouldn't have touched. Thread-safety bugs, cache invalidation bugs, dictionary key corruption, a surprisingly large category of hard-to-debug problems traces back to unexpected mutation. Frozen dataclasses give you a simple, low-overhead way to enforce immutability at the class level.

When you mark a dataclass as frozen=True, Python installs __setattr__ and __delattr__ that raise FrozenInstanceError on any attempt to change a field after construction. This isn't a convention or a gentlemen's agreement, it's enforced at runtime. Any code that tries to mutate the object will get an immediate, clear error rather than silently corrupting state that might not surface until much later.

The practical benefits go beyond preventing bugs. Frozen dataclasses are inherently thread-safe because concurrent reads of immutable state don't need synchronization. They're also hashable by default, which means you can use them as dictionary keys or set members, a capability you can't get from a regular mutable dataclass without opting into unsafe_hash. For AI/ML work specifically, frozen dataclasses are ideal for representing hyperparameter configurations, feature definitions, or model metadata where you want a guaranteed snapshot that can't drift.

python
from dataclasses import dataclass
 
@dataclass(frozen=True)
class Coordinate:
    x: float
    y: float
    z: float = 0.0
 
c = Coordinate(10, 20)
print(c)  # Coordinate(x=10, y=20, z=0.0)
 
c.x = 15  # TypeError: cannot assign to field 'x'

When you set frozen=True, the dataclass becomes immutable. You can't modify fields after creation. The decorator also auto-generates __hash__ (for frozen dataclasses), so you can use them as dictionary keys:

python
@dataclass(frozen=True)
class Point:
    x: float
    y: float
 
cache = {}
p1 = Point(0, 0)
p2 = Point(1, 1)
 
cache[p1] = "origin"
cache[p2] = "northeast"
 
print(cache[Point(0, 0)])  # origin

This pattern comes up constantly in caching, memoization, and configuration management. Because the point is frozen, you can be confident the hash won't change after you store it as a key. If you could mutate p1.x after using it as a dictionary key, the dictionary's internal hash table would become corrupted, lookups would fail or return wrong values. The frozen constraint prevents that entire class of bugs.

The hidden layer: Why is immutability useful here? Because dictionaries hash their keys. If you could mutate a point's coordinates after using it as a key, the hash would change, and lookups would break. Frozen dataclasses guarantee stability. They're also thread-safe by default, no two threads can corrupt shared state if nothing can be modified.

post_init: Custom Logic After Initialization

Sometimes you need to do something with the fields after __init__ runs. That's what __post_init__ is for. It's called automatically by the generated __init__ at the very end, after all fields have been assigned. This gives you a clean hook for validation, type coercion, and computing derived fields without having to write the entire __init__ manually.

The most common use case is type conversion, you receive a string from JSON or a user input and want to convert it to a richer type like datetime before the object is fully constructed. Without __post_init__, you'd have to override __init__ entirely and lose the dataclass's automatic field handling.

python
from dataclasses import dataclass
import datetime
 
@dataclass
class Event:
    title: str
    start_date: str
    duration_days: int
 
    def __post_init__(self):
        # Convert string date to datetime
        self.start_date = datetime.datetime.fromisoformat(self.start_date)
        # Compute end date
        self.end_date = self.start_date + datetime.timedelta(days=self.duration_days)
 
e = Event("Conference", "2026-03-15", 3)
print(e.start_date)  # 2026-03-15 00:00:00
print(e.end_date)    # 2026-03-18 00:00:00

The __post_init__ method is called automatically after __init__ finishes. It's perfect for:

  • Type conversions
  • Validation
  • Computing derived fields
  • Setting up internal state

You can also use field(init=False) with __post_init__ to add computed fields that aren't passed to __init__. This pattern makes the derived nature of the field explicit in the class definition itself, which is better documentation than hiding the computation in __post_init__ without the annotation.

python
from dataclasses import dataclass, field
 
@dataclass
class Product:
    name: str
    price: float
    tax_rate: float = 0.1
    total_price: float = field(init=False)
 
    def __post_init__(self):
        self.total_price = self.price * (1 + self.tax_rate)
 
p = Product("Laptop", 1000)
print(p.total_price)  # 1100.0

slots Memory Benefits

Here's a problem few people notice until they're running on constrained hardware or processing millions of objects: every Python object stores instance attributes in a dictionary called __dict__. This gives you flexibility, you can add attributes at runtime, but it costs memory. Each dictionary entry takes space. With thousands or millions of objects, that adds up fast.

The numbers are stark once you measure them. A typical Python object without __slots__ carries roughly 200-300 bytes of overhead just for the dictionary structure, on top of the space for the actual data. That overhead is fixed whether you have three attributes or ten. For an object with three float coordinates, you might be storing 24 bytes of actual data inside 250+ bytes of container. When you scale that to a million objects, which is completely normal in a data pipeline or ML training loop, you're burning hundreds of megabytes on infrastructure that doesn't hold any of your actual data.

__slots__ tells Python: "This class will only ever have these specific attributes. Don't create a dictionary. Store them directly in memory." Python pre-allocates the exact amount of memory needed for those attributes and stores them as C-level descriptors on the class. The result looks like a C struct: compact, fixed, and fast to access. You lose the ability to add attributes at runtime, but for data-oriented classes, which is exactly the use case for dataclasses, that tradeoff is almost always worth it.

python
class Person:
    __slots__ = ('name', 'age', 'email')
 
    def __init__(self, name, age, email):
        self.name = name
        self.age = age
        self.email = email
 
p = Person("Alice", 30, "alice@example.com")
print(hasattr(p, '__dict__'))  # False
print(p.name)  # Alice

With __slots__, you can't add attributes dynamically:

python
p.phone = "555-1234"  # AttributeError: 'Person' object has no attribute 'phone'

But the memory savings are significant. A class with hundreds of instances can save 30-50% memory with __slots__. At a million instances, the difference between a slotted and un-slotted class with three float fields can be the difference between 50MB and 300MB of RAM usage.

The hidden layer: Why does this work? Without __slots__, every instance carries a pointer to a dictionary. The dictionary stores key-value pairs. That's overhead. With __slots__, Python preallocates memory for the exact attributes you declared, stored as descriptors on the class. It's more like a C struct.

Dataclasses + slots (Python 3.10+)

Here's where it gets beautiful. Starting in Python 3.10, you can combine dataclasses with __slots__. Before 3.10, you could define __slots__ manually inside a dataclass, but the decorator didn't know about it and you had to keep both in sync, a maintenance burden that defeated part of the purpose. Python 3.10 solved this properly with the slots=True parameter.

When you pass slots=True, the dataclass machinery inspects your field definitions, constructs a __slots__ tuple from them, and builds a new class with those slots rather than the default __dict__. You get all the memory savings of __slots__ with none of the manual maintenance. As you add or rename fields, the slots stay in sync automatically.

python
from dataclasses import dataclass
 
@dataclass(slots=True)
class Person:
    name: str
    age: int
    email: str
 
p = Person("Alice", 30, "alice@example.com")
print(p)  # Person(name='Alice', age=30, email='alice@example.com')
print(hasattr(p, '__dict__'))  # False

That's it. slots=True tells the dataclass decorator to auto-generate __slots__ based on the fields you defined. All the benefits of __slots__ without the manual plumbing. You can also combine slots=True with frozen=True for a class that is both memory-efficient and immutable, a powerful combination for large-scale data processing.

If you're on Python 3.9 or earlier, you can do it manually:

python
from dataclasses import dataclass
 
@dataclass
class Person:
    __slots__ = ('name', 'age', 'email')
    name: str
    age: int
    email: str

This works, but the dataclass decorator doesn't know about __slots__, so you have to be careful about consistency. Python 3.10+ does it right. If you're maintaining a codebase that needs to support Python 3.9, consider whether the memory benefits are worth the manual synchronization, or whether upgrading the runtime is the better move.

Inheritance with Dataclasses

Dataclasses play nicely with inheritance, but there are rules. The key principle is that the generated __init__ signature is built by walking the class hierarchy from parent to child, collecting fields in order. This means the parent's fields always appear before the child's fields in the constructor.

Understanding this ordering matters more than it might seem. When you're building a hierarchy of configuration classes, or a set of related data models, you need to plan field ordering across the entire hierarchy to avoid the "non-default field after default field" error. That error isn't always obvious when it happens across multiple files in a large codebase.

python
from dataclasses import dataclass
 
@dataclass
class Person:
    name: str
    age: int
 
@dataclass
class Employee(Person):
    employee_id: str
    department: str
 
e = Employee("Alice", 30, "E12345", "Engineering")
print(e)  # Employee(name='Alice', age=30, employee_id='E12345', department='Engineering')
print(e == Employee("Alice", 30, "E12345", "Engineering"))  # True

The rule: Fields without defaults must come before fields with defaults. This applies across inheritance:

python
@dataclass
class Person:
    name: str
    age: int = 0
 
@dataclass
class Employee(Person):
    # ERROR: non-default field employee_id after default field age
    employee_id: str
    department: str = "Unknown"

To fix it, give employee_id a default:

python
@dataclass
class Employee(Person):
    employee_id: str = ""
    department: str = "Unknown"

Or restructure your hierarchy. This constraint exists because __init__ must accept positional arguments in order, you can't have __init__(name, age=0, employee_id) because it's confusing and error-prone. Python requires that keyword arguments with defaults come after positional arguments without defaults in any function signature, and the generated __init__ follows the same rule.

Common Dataclass Mistakes

Even experienced Python developers make predictable mistakes with dataclasses. Knowing them in advance saves debugging time.

The most frequent mistake is the mutable default trap, which we covered already, using [] or {} as a default instead of field(default_factory=list). The decorator catches this for dataclasses, but it's worth reinforcing because the same mistake in regular functions doesn't get caught at all.

The second common mistake is confusing frozen=True with deep immutability. A frozen dataclass prevents you from reassigning its fields, but it doesn't freeze the contents of mutable objects inside those fields. If a frozen dataclass has a list field, you can't replace the list, but you can still append to it. The outer container is immutable; the contents are not. This catches people off guard when they're using frozen dataclasses for cache keys, if the list inside mutates, the hash doesn't change, but the value associated with that key is now inconsistent with the key's content.

The third mistake is using dataclasses for classes that need custom __init__ logic beyond what __post_init__ can handle. Dataclasses generate __init__ for you. If your initialization is complex, if you need conditional logic to determine which fields to set, or you need to accept arguments that aren't fields, you'll fight the decorator. In those cases, a regular class with a carefully written __init__ is cleaner.

The fourth mistake is forgetting field ordering in inheritance hierarchies. When you have a parent class with default fields and try to add non-default fields in a child class, you get a TypeError at class definition time. The fix is always to give the child's non-default fields explicit defaults, or to restructure the hierarchy so non-default fields come first.

Finally, people sometimes assume that because they annotated a class with @dataclass, it will automatically serialize to and from JSON. It won't. Dataclasses give you Python objects with proper equality and representation, but JSON serialization requires explicit code, a library like dacite or cattrs, or using Pydantic instead. The standard json.dumps will reject a dataclass unless you convert it to a dict first with dataclasses.asdict().

Dataclasses vs. NamedTuple vs. Regular Classes

You've now got three tools for data structures. When do you use which?

Dataclasses are best when:

  • You need a simple data container with __init__, __repr__, __eq__
  • You want mutability (by default)
  • You need __post_init__ for custom initialization logic
  • You want inheritance
  • You're working with Python 3.7+

NamedTuple is best when:

  • You want immutable, lightweight data containers
  • You want tuple-like behavior (unpacking, indexing)
  • You don't need to modify the data after creation
  • You want type hints without the dataclass syntax
python
from typing import NamedTuple
 
class Person(NamedTuple):
    name: str
    age: int
    email: str
 
p = Person("Alice", 30, "alice@example.com")
print(p[0])  # Alice
name, age, email = p  # unpacking works

Regular classes are best when:

  • You need complex behavior, multiple methods, inheritance chains
  • You need custom __init__ logic that dataclasses can't express
  • You're modeling domain objects, not just data
  • You need instance methods that do real work

Real example: use a dataclass for an API response, use a regular class for a service that fetches and processes the data.

Practical Example: Configuration Management

Let's tie this together. You're building an app that reads configuration from a file. This is one of the clearest wins for dataclasses, configuration objects are pure data containers with optional nested structure, they benefit from equality comparison when diffing configs, and they almost always have fields that shouldn't appear in logs.

Notice how the repr=False on the password field provides safety without any extra work. Notice how nesting DatabaseConfig inside AppConfig works naturally because dataclasses compose, each class manages its own fields, and constructing nested structures requires just one extra function call per level.

python
from dataclasses import dataclass, field
from typing import Dict
import json
 
@dataclass
class DatabaseConfig:
    host: str = "localhost"
    port: int = 5432
    username: str = "postgres"
    password: str = field(default="", repr=False)  # Don't leak passwords
 
    def connection_string(self):
        return f"postgres://{self.username}:{self.password}@{self.host}:{self.port}"
 
@dataclass
class AppConfig:
    app_name: str
    debug: bool = False
    database: DatabaseConfig = field(default_factory=DatabaseConfig)
    allowed_origins: list = field(default_factory=list)
 
# Load from JSON
config_data = json.loads('''
{
    "app_name": "MyAPI",
    "debug": true,
    "database": {
        "host": "db.example.com",
        "port": 5432,
        "username": "app_user",
        "password": "secret123"
    },
    "allowed_origins": ["https://example.com"]
}
''')
 
# Manually construct (dataclasses don't auto-parse JSON)
config = AppConfig(
    app_name=config_data["app_name"],
    debug=config_data["debug"],
    database=DatabaseConfig(**config_data["database"]),
    allowed_origins=config_data["allowed_origins"]
)
 
print(config)
# AppConfig(app_name='MyAPI', debug=True, database=DatabaseConfig(host='db.example.com', port=5432, username='app_user'), allowed_origins=['https://example.com'])
 
print(config.database.connection_string())
# postgres://app_user:secret123@db.example.com:5432

See what happens? The dataclasses are clean, readable, and do exactly what you need. No boilerplate, but full type safety and automatic comparison. You can compare two AppConfig objects with == to detect configuration drift, log them without leaking credentials, and extend them by adding fields without touching any of the generated methods.

Advanced: The Order Matters

One more thing to watch: the order of fields in your dataclass definition matters. The __init__ signature is generated in the order you define them. This is the same ordering constraint that applies to regular function signatures, and the error messages you get when you violate it are clear, but the fix isn't always obvious if you're not expecting the constraint.

python
@dataclass
class Point:
    x: float
    y: float
    z: float = 0.0
 
p = Point(1, 2)  # Works: x=1, y=2, z=0.0

If you define fields with defaults before fields without defaults, you'll get an error:

python
@dataclass
class BadPoint:
    z: float = 0.0
    x: float  # SyntaxError: non-default argument follows default argument
    y: float

The dataclass decorator will raise a ValueError when it tries to generate __init__. This is the same rule as regular Python functions: you can't have a required argument after an optional one. The generated __init__ has to be a valid Python function signature, and Python function signatures require that all required (non-default) parameters come before optional (default) parameters.

If you need to reorder, use field() with default or default_factory for all trailing fields.

Dataclass Comparison and Hashing

Equality and hashing are handled automatically, but there are nuances worth understanding. By default, @dataclass generates both __eq__ and __hash__ for non-frozen dataclasses. The generated __eq__ compares all fields in order, which is the behavior you almost always want. But the hashing story is more nuanced, and getting it wrong leads to hard-to-debug failures with dictionaries and sets.

python
from dataclasses import dataclass
 
@dataclass
class Person:
    name: str
    age: int
 
p1 = Person("Alice", 30)
p2 = Person("Alice", 30)
p3 = Person("Bob", 25)
 
print(p1 == p2)  # True (same field values)
print(p1 is p2)  # False (different objects)
print(p1 != p3)  # True

But notice: __hash__ is not generated for mutable dataclasses by default:

python
@dataclass
class Person:
    name: str
    age: int
 
people = {Person("Alice", 30): "admin"}  # TypeError: unhashable type

This is intentional. If a dataclass is mutable and you use it as a dictionary key, someone could mutate the fields and break the dictionary's internal structure. That's bad.

But you can enable hashing if you want:

python
@dataclass(unsafe_hash=True)
class Person:
    name: str
    age: int
 
people = {Person("Alice", 30): "admin"}  # Works, but risky
p = Person("Alice", 30)
p.age = 31  # Mutated, but still in the dict

The hidden layer: The unsafe_hash=True parameter exists because sometimes you need it, maybe you control mutations, or you're using a framework that requires hashability. But the name says it all: it's unsafe. The safer pattern is frozen dataclasses:

python
@dataclass(frozen=True)
class Person:
    name: str
    age: int
 
people = {Person("Alice", 30): "admin"}  # Works safely
print(hash(Person("Alice", 30)))  # Hashable, immutable

With frozen=True, the dataclass is immutable by definition, so hashing is safe. Two frozen instances with identical fields will have identical hashes and compare as equal, exactly the semantics you want for dictionary keys.

You can also control comparison with the eq parameter:

python
@dataclass(eq=False)
class Person:
    name: str
    age: int
    _id: int = field(default=0)
 
p1 = Person("Alice", 30, _id=1)
p2 = Person("Alice", 30, _id=2)
 
print(p1 == p2)  # False (because eq=False means no __eq__ is generated)

Actually, that's not quite right. Let me show the real use case:

python
@dataclass(eq=False)
class User:
    username: str
    email: str
    _internal_id: int = field(default=0)
 
u1 = User("alice", "alice@example.com", _internal_id=123)
u2 = User("alice", "alice@example.com", _internal_id=456)
 
print(u1 == u2)  # False (because eq=False means no __eq__ is generated)
print(u1.username == u2.username)  # True

When you set eq=False, the dataclass doesn't generate __eq__, so comparison falls back to object identity (same as regular classes).

Dataclasses with Validators

Dataclasses themselves don't have built-in validators, but __post_init__ lets you add them. This is the right place to enforce invariants, conditions that must hold for the object to be in a valid state. If the object can't be constructed in a valid state, raising an exception in __post_init__ is cleaner than letting an invalid object exist and failing later.

Keep validators focused on structural constraints that can be checked from the fields alone. Business logic that requires database queries or network calls belongs elsewhere. The goal is to guarantee that if you have an instance of the class, it satisfies the basic invariants, not to perform full application-level validation.

python
from dataclasses import dataclass
 
@dataclass
class User:
    username: str
    age: int
 
    def __post_init__(self):
        if not self.username:
            raise ValueError("username cannot be empty")
        if self.age < 0 or self.age > 150:
            raise ValueError(f"age must be 0-150, got {self.age}")
 
u = User("", 25)  # ValueError: username cannot be empty

For more robust validation, libraries like Pydantic go further:

python
from pydantic import BaseModel, Field
 
class User(BaseModel):
    username: str
    age: int = Field(ge=0, le=150)
 
u = User(username="alice", age=25)  # Works
u = User(username="alice", age=200)  # ValidationError

But Pydantic is a separate library. For vanilla dataclasses, __post_init__ is the pattern.

Field Metadata and Custom Behavior

field() accepts a metadata parameter that you can use to attach arbitrary data. This is a way to embed schema information directly in the class definition, useful for building serialization frameworks, validation pipelines, or documentation generators that need to know things about fields that aren't captured by the type annotation alone.

The metadata is stored as an immutable mapping on each Field object, accessible via the fields() function. This means you can write generic code that walks any dataclass's fields and applies rules based on the metadata, without hardcoding field names. That's the pattern used by many serialization libraries.

python
from dataclasses import dataclass, field
 
@dataclass
class Product:
    name: str
    price: float = field(metadata={"currency": "USD"})
    description: str = field(default="", metadata={"max_length": 500})
 
from dataclasses import fields
 
for f in fields(Product):
    print(f"{f.name}: {f.metadata}")
 
# name: mappingproxy({})
# price: mappingproxy({'currency': 'USD'})
# description: mappingproxy({'max_length': 500})

The fields() function returns Field objects that give you introspection. You can walk through all fields and their metadata programmatically:

python
from dataclasses import fields, Field
 
def validate_product(p: Product):
    for f in fields(p):
        value = getattr(p, f.name)
        if f.metadata and "max_length" in f.metadata:
            max_len = f.metadata["max_length"]
            if len(value) > max_len:
                raise ValueError(f"{f.name} exceeds max length {max_len}")
 
# Validate
p = Product("Widget", 9.99, "x" * 600)
validate_product(p)  # ValueError

This is useful for building validation frameworks or document serializers.

Dataclasses and Type Hints

Dataclasses require type hints, without them, the decorator doesn't know which attributes to include. This is actually one of the cleaner aspects of the design: type hints serve double duty as both documentation and the mechanism for declaring dataclass fields. Any attribute without a type annotation is treated as a regular class attribute, not a field.

This distinction matters for class variables. If you want a class-level constant that all instances share, not a per-instance attribute, you declare it without a type annotation or use ClassVar from the typing module. Getting this wrong means the constant accidentally becomes a constructor parameter, which is confusing and often breaks things.

python
from dataclasses import dataclass
 
@dataclass
class Point:
    x: float
    y: float
    # This field is NOT included (no type hint)
    internal_state = None
 
p = Point(1, 2)
print(p.internal_state)  # None (class attribute, not instance)
p.internal_state = "modified"
print(p.internal_state)  # "modified" (but it's a class attribute)

This is a feature: if you don't annotate something, it's treated as a regular class attribute, not a dataclass field.

You can also use generic type hints:

python
from dataclasses import dataclass, field
from typing import List, Dict, Optional
 
@dataclass
class Blog:
    title: str
    author: str
    tags: List[str] = field(default_factory=list)
    metadata: Dict[str, str] = field(default_factory=dict)
    published_at: Optional[str] = None
 
b = Blog("My Post", "Alice", tags=["python", "dataclasses"])
print(b)

Type hints work with IDE autocompletion, type checkers like mypy, and dataclass introspection.

Slots and Performance Impact

Let's quantify the memory savings from __slots__. The numbers are real and they matter once your data pipeline reaches any meaningful scale. A benchmark at a million objects shows the difference clearly.

python
from dataclasses import dataclass
import sys
 
# Without slots
@dataclass
class PointNoSlots:
    x: float
    y: float
    z: float
 
# With slots (Python 3.10+)
@dataclass(slots=True)
class PointWithSlots:
    x: float
    y: float
    z: float
 
p1 = PointNoSlots(1, 2, 3)
p2 = PointWithSlots(1, 2, 3)
 
print(f"Without slots: {sys.getsizeof(p1) + sys.getsizeof(p1.__dict__)} bytes")
# Without slots: ~296 bytes
 
print(f"With slots: {sys.getsizeof(p2)} bytes")
# With slots: ~56 bytes

For a single object, the difference is small. But scale to a million points:

python
import time
 
# Create a million points without slots
start = time.time()
points_no_slots = [PointNoSlots(i, i+1, i+2) for i in range(1_000_000)]
time_no_slots = time.time() - start
 
# Create a million points with slots
start = time.time()
points_with_slots = [PointWithSlots(i, i+1, i+2) for i in range(1_000_000)]
time_with_slots = time.time() - start
 
print(f"No slots time: {time_no_slots:.3f}s")
print(f"With slots time: {time_with_slots:.3f}s")
# Memory savings: ~240MB vs ~50MB
# Time is comparable, but memory is dramatically lower

At a million objects, the slotted version uses roughly 50MB versus 240MB for the un-slotted version. That's not a micro-optimization, that's the difference between fitting a dataset in RAM versus hitting swap, or between a model that trains on your machine versus one that requires a bigger instance. In ML data pipelines where you're holding millions of feature vectors, training examples, or graph nodes in memory, slots=True is one of the highest-leverage optimizations available with zero algorithmic complexity added.

The hidden layer: __slots__ doesn't make your code run faster per se, it saves memory. But memory efficiency has cascading benefits: better CPU cache locality, fewer garbage collections, less swapping. For large datasets, these add up.

However, __slots__ has tradeoffs:

  • You can't add attributes dynamically
  • You lose flexibility
  • Multiple inheritance gets complicated
  • Your code is more rigid

Use it when you have many objects that would otherwise bloat memory. Don't use it prematurely.

Copying and Replacing Fields

Sometimes you want to create a modified copy of a dataclass instance. The dataclasses.replace() function does this. It's the functional programming approach to mutation: instead of changing an existing object, you create a new object that's identical except for the fields you specify. This pattern works especially well with frozen dataclasses, where you literally can't mutate the original and need a clean way to derive modified versions.

replace() is also safer than manual construction because it copies all fields you don't explicitly change. If you add a new field to the dataclass later, replace() calls throughout your codebase don't need updating, they'll automatically copy the new field unless you explicitly override it.

python
from dataclasses import dataclass, replace
 
@dataclass
class Person:
    name: str
    age: int
    email: str
 
p1 = Person("Alice", 30, "alice@example.com")
p2 = replace(p1, age=31)
 
print(p1)  # Person(name='Alice', age=30, email='alice@example.com')
print(p2)  # Person(name='Alice', age=31, email='alice@example.com')

This creates a new instance with some fields changed, others copied. It's useful for immutable patterns where you don't mutate objects, you create new versions:

python
# Immutable workflow
person = Person("Bob", 25, "bob@example.com")
 
# Birthday? Create a new person
person = replace(person, age=person.age + 1)
 
# Email changed? Create a new person
person = replace(person, email="newemail@example.com")

This pattern is safer than mutation because it's explicit and versioned.

Dataclasses in Action: Real-World API Response Parsing

Here's a complete, realistic example, parsing a GitHub API response. This is the kind of code you write constantly in any backend service or data pipeline, and it's where dataclasses earn their keep most visibly. The alternative, accessing nested dictionaries with string keys, is error-prone, provides no IDE support, and gives you no type checking. A single typo in a key name becomes a KeyError at runtime rather than a type error at development time.

With dataclasses, you define the shape of the response once, construct the object at the boundary where raw data enters your system, and then work with typed, structured objects everywhere else. Your editor knows what fields exist. Your type checker catches mistakes. And the representation is readable without any extra formatting code.

python
from dataclasses import dataclass, field
from typing import List, Optional
import json
 
@dataclass
class User:
    login: str
    id: int
    avatar_url: str = ""
    bio: Optional[str] = None
 
@dataclass
class Repository:
    id: int
    name: str
    full_name: str
    owner: User
    description: Optional[str] = None
    url: str = ""
    stars: int = field(default=0, metadata={"json_key": "stargazers_count"})
    language: Optional[str] = None
 
# Parse API response
data = json.loads('''
{
    "id": 123456,
    "name": "my-project",
    "full_name": "alice/my-project",
    "owner": {
        "login": "alice",
        "id": 1,
        "avatar_url": "https://avatars.githubusercontent.com/u/1"
    },
    "description": "An awesome project",
    "url": "https://github.com/alice/my-project",
    "stargazers_count": 42,
    "language": "Python"
}
''')
 
# Manual construction (dataclasses don't auto-parse JSON)
repo = Repository(
    id=data["id"],
    name=data["name"],
    full_name=data["full_name"],
    owner=User(
        login=data["owner"]["login"],
        id=data["owner"]["id"],
        avatar_url=data["owner"]["avatar_url"]
    ),
    description=data.get("description"),
    url=data["url"],
    stars=data["stargazers_count"],
    language=data.get("language")
)
 
print(repo)
# Repository(id=123456, name='my-project', full_name='alice/my-project',
#            owner=User(...), description='An awesome project', url='...',
#            stars=42, language='Python')
 
print(f"{repo.owner.login}'s repo has {repo.stars} stars")
# alice's repo has 42 stars

With dataclasses, your API responses are typed, structured, and easy to work with. No more digging through nested dictionaries.

When NOT to Use Dataclasses

Dataclasses aren't for everything. Don't use them when:

  • You need deeply custom __init__ logic (use a regular class instead)
  • You're modeling a domain object with rich behavior (use a regular class)
  • You need to support Python < 3.7 (use NamedTuple or regular classes)
  • Your object is essentially a thin wrapper around external state (use a regular class with explicit refresh methods)

The hidden layer: Dataclasses are a tool for reducing boilerplate in a specific case: "I have a bunch of fields and I want standard behavior." If your class doesn't fit that case, don't force it.

Putting It All Together

Dataclasses, __slots__, and frozen classes each solve a distinct problem, and they're designed to work together. Here's how to think about which combination you need.

Start with a plain @dataclass for any class whose primary purpose is holding structured data. You get automatic __init__, __repr__, and __eq__ with no boilerplate. Add __post_init__ when you need validation or computed fields. Use field(repr=False) for sensitive data, field(default_factory=...) for mutable defaults, and field(init=False) for derived attributes.

Add frozen=True when immutability matters, for configuration snapshots, cache keys, dictionary keys, or any value that represents a fixed moment in time rather than a mutable state. Frozen dataclasses are hashable, thread-safe, and carry an explicit contract that says "this value doesn't change."

Add slots=True (Python 3.10+) when you're creating many instances of the same class, think data records, feature vectors, graph nodes, or any collection of objects in a data pipeline. The memory savings are real and the cost is minor: you give up dynamic attribute assignment, which you probably weren't doing anyway for a data container class.

The combination of @dataclass(slots=True, frozen=True) is particularly powerful for large-scale data work: you get memory-efficient, immutable, hashable objects that can serve as dictionary keys, set members, or elements in a sorted collection. That's a lot of functionality for a decorator and two flags.

Know the tradeoffs. Order your fields correctly. Reach for field() whenever you need a mutable default, a hidden field, or a computed value. And remember: the goal isn't to use dataclasses everywhere, it's to use them where they eliminate boilerplate without adding complexity. When they fit, they fit perfectly. When they don't, Python's regular classes are right there.

Need help implementing this?

We build automation systems like this for clients every day.

Discuss Your Project