When people think about modeling data in Python, @dataclass is usually the first tool that comes to mind. And for good reason: it's concise, readable, and built into the standard library.
But Python offers several different ways to represent data, each designed for slightly different use cases.
In this article, we'll explore the most common approaches, compare their strengths and weaknesses, and discuss when you should choose one over another.
1. Regular Classes
Before Python 3.7, this was the standard way to model objects.
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
person = Person("Alice", 30)
Regular classes are extremely flexible.
You can define methods, properties, inheritance hierarchies, descriptors, custom constructors. Anything Python supports.
The downside is boilerplate. Even a simple data container requires writing an initializer and, if desired, __repr__, __eq__, ordering methods, and more.
Best when:
- The object has significant behavior.
- You need full control over object construction.
- The class is more than just data.
2. Dataclasses
Introduced in Python 3.7, dataclasses automate much of the repetitive work involved in creating data-centric classes.
from dataclasses import dataclass
@dataclass
class Person:
name: str
age: int
This automatically generates:
__init__
__repr__
__eq__
And optionally:
- ordering
- hashing
- immutability (frozen=True)
- keyword-only fields
- default factories
- post-initialization hooks
The result is concise code that remains fully compatible with normal Python classes.
Best when:
- Your class primarily stores data.
- You still want methods and business logic.
- You want readable, maintainable code.
For most modern Python projects, this is the default choice.
3. Dataclasses with slots=True
Python objects normally store their attributes in a dictionary (__dict__).
For large numbers of objects, that dictionary consumes additional memory.
Python 3.10 introduced a convenient way to combine dataclasses with __slots__.
from dataclasses import dataclass
@dataclass(slots=True)
class Person:
name: str
age: int
Benefits include:
- lower memory usage
- slightly faster attribute access
- prevention of accidental new attributes
The trade-off is a small reduction in flexibility, since instances no longer have a normal __dict__.
Best when:
- You create many instances.
- The object's attributes are fixed.
- Memory efficiency matters.
4. Classes with slots
Before slots=True was added to dataclasses, developers manually declared slots.
class Person:
__slots__ = ("name", "age")
def __init__(self, name, age):
self.name = name
self.age = age
This produces many of the same performance characteristics as dataclass(slots=True).
However, it requires more manual work and can complicate inheritance.
Best when:
- You're writing highly optimized classes.
- You're not using dataclasses.
5. NamedTuple
Sometimes objects should never change after creation.
That's where NamedTupleshines.
from typing import NamedTuple
class Person(NamedTuple):
name: str
age: int
Instances are immutable.
person = Person("Alice", 30)
person.name
person[0]
Unlike dataclasses, NamedTuple behaves like both an object and a tuple.
This makes it lightweight, hashable, and efficient.
Best when:
- Data should be immutable.
- The object is essentially a record.
- You want tuple compatibility.
6. collections.namedtuple
Before type annotations became common, Python provided collections.namedtuple.
from collections import namedtuple
Person = namedtuple("Person", ["name", "age"])
It offers many of the same runtime characteristics as NamedTuple.
The main differences are:
- no class syntax
- no type annotations
- older API
For new projects, typing.NamedTuple is generally preferred.
7. TypedDict
Sometimes your data naturally belongs in a dictionary.
Instead of creating a custom object, you can describe the dictionary's expected structure.
from typing import TypedDict
class Person(TypedDict):
name: str
age: int
Usage:
person: Person = {
"name": "Alice",
"age": 30,
}
At runtime, this is still just a normal dictionary.
The type information exists primarily for static type checkers.
Best when:
- You're working with JSON.
- APIs already use dictionaries.
- You don't need object methods.
8. SimpleNamespace
Sometimes you simply want attribute access without defining a class.
from types import SimpleNamespace
person = SimpleNamespace(
name="Alice",
age=30,
)
Now you can write person.name instead of person["name"].
Internally, SimpleNamespace stores everything in a regular __dict__.
It's lightweight, dynamic, and convenient for quick scripts.
Best when:
- Rapid prototyping.
- Small utilities.
- Dynamic attributes.
9. Pydantic Models
Sometimes storing data isn't enough.
You also want validation, parsing, serialization, and automatic type conversion.
That's where Pydantic excels.
from pydantic import BaseModel
class Person(BaseModel):
name: str
age: int
Now this works:
person = Person(
name="Alice",
age="30",
)
print(person.age)
# 30
The string is automatically converted into an integer.
Pydantic also provides:
- JSON serialization
- schema generation
- validation errors
- nested models
- parsing external data
It's one of the foundations of modern Python web development.
Best when:
- Building APIs.
- Reading configuration files.
- Validating external input.
- Working with typed JSON.
I hope that this article helped you.
Note: This article was written with the assistance of AI, primarily to verify syntax, grammar, formatting, and technical concepts.
Top comments (0)