Dataclasses vs NamedTuple: Choose the Right Tool
Both Python dataclasses and NamedTuple create lightweight data structures, but they serve different purposes. NamedTuple is immutable, tuple-based, and best for small, fixed records; dataclasses are mutable by default, class-based, and better for complex objects with behavior. Choosing the wrong one costs you flexibility later, so understanding the trade-offs is essential.
I've seen teams choose NamedTuple for convenience, only to hit performance or design walls when they needed to add optional fields or validation. This article shows the exact comparison so you can make the right choice from the start.
What Is NamedTuple?
A NamedTuple is a tuple subclass with named fields. It is immutable (you cannot change a field after creation) and memory-efficient because it inherits from tuple. You define a NamedTuple using either the functional syntax or type-hinting syntax.
from typing import NamedTuple
# Type-hinting syntax (preferred, 3.6+)
class Point(NamedTuple):
x: float
y: float
# Functional syntax (legacy)
Point = namedtuple('Point', ['x', 'y'])
p = Point(10.5, 20.3)
print(p.x) # 10.5
print(p[0]) # 10.5 (also index-accessible like a tuple)
print(p) # Point(x=10.5, y=20.3)
NamedTuple fields are read-only. If you try p.x = 15, you'll get an AttributeError.
Side-by-Side Comparison
| Aspect | Dataclass | NamedTuple |
|---|---|---|
| Mutability | Mutable by default | Immutable (always) |
| Memory overhead | ~56 bytes per instance (stores __dict__) | ~40 bytes (tuple-based, no __dict__) |
| Speed | Comparable to hand-written classes | 5–10% faster attribute access (no dict lookup) |
| Inherits from | object (regular class) | tuple (immutable, tuple-like) |
| Tuple unpacking | x, y = Point(1, 2) works but less natural | Natural: x, y = point |
| Hashing | Unhashable by default (mutable) | Hashable (immutable) |
| Methods | Can add instance methods easily | Can add methods, but less idiomatic |
| Default values | Yes, via field(default=...) | Yes, via assignment |
| Frozen mode | Yes, via frozen=True | Always frozen |
| Optional fields | Supported; see Optional[T] | Supported; defaults work well |
When to Use NamedTuple
1. Small, Immutable Records
If your data is inherently immutable—like coordinates, database row keys, or function return values—NamedTuple is natural and efficient.
from typing import NamedTuple
class RGB(NamedTuple):
red: int
green: int
blue: int
palette = {
"white": RGB(255, 255, 255),
"black": RGB(0, 0, 0),
}
2. Use as Dictionary Keys or in Sets
Only hashable types can be dict keys or set members. NamedTuples are immutable and hashable by default.
colors_seen = {RGB(255, 0, 0), RGB(0, 255, 0)} # Works
3. API Return Values
When a function returns a structured result that the caller should not modify, NamedTuple signals immutability.
class QueryResult(NamedTuple):
rows: int
query_time_ms: float
error: str | None
def run_query(sql: str) -> QueryResult:
# ...
return QueryResult(rows=100, query_time_ms=45.2, error=None)
4. Minimal Overhead Matters
In data-heavy loops (processing millions of records), NamedTuple's smaller memory footprint can add up.
According to benchmarks from the Python Enhancement Proposal discussion (2017), NamedTuple uses approximately 16 bytes less per instance than a mutable dataclass.
When to Use Dataclasses
1. Objects with Behavior
If you plan to add methods, validation, or computed properties, dataclasses are more natural.
from dataclasses import dataclass
@dataclass
class Rectangle:
width: float
height: float
def area(self) -> float:
return self.width * self.height
def scale(self, factor: float) -> None:
self.width *= factor
self.height *= factor
rect = Rectangle(10, 20)
print(rect.area()) # 200.0
2. Complex Initialization Logic
Post-init validation and field factories (covered in later articles) are idiomatic in dataclasses.
from dataclasses import dataclass, field
@dataclass
class Config:
name: str
tags: list[str] = field(default_factory=list)
def __post_init__(self) -> None:
if not self.name:
raise ValueError("name cannot be empty")
3. Evolving Data Models
Adding optional fields to a dataclass is straightforward; adding them to a NamedTuple requires changing the signature everywhere.
4. Mutations Are Part of Your Design
If objects change state over their lifetime (e.g., a connection pool, a user session), mutability is appropriate.
@dataclass
class Session:
user_id: int
logged_in: bool = True
last_activity: str = ""
def logout(self) -> None:
self.logged_in = False
Frozen Dataclass: Best of Both Worlds?
You can make a dataclass immutable with frozen=True, which generates __hash__ and prevents field reassignment. This gives you a hashable, immutable data class with all dataclass features.
from dataclasses import dataclass
@dataclass(frozen=True)
class ImmutablePoint:
x: float
y: float
p = ImmutablePoint(10, 20)
# p.x = 15 # FrozenInstanceError!
coords = {p, ImmutablePoint(5, 5)} # Works; frozen dataclasses are hashable
A frozen dataclass is nearly identical to a NamedTuple in behavior, but it's a dataclass class rather than a tuple subclass. It is slightly more memory-hungry than NamedTuple but gains full dataclass features (post-init hooks, field metadata, inheritance).
Real-World Decision Tree
| Question | If Yes | If No |
|---|---|---|
| Is the data inherently immutable? | Use NamedTuple | Use dataclass |
| Must it be hashable (dict key / set member)? | Use NamedTuple or frozen=True dataclass | Dataclass is fine |
| Will you add methods or validation? | Use dataclass | NamedTuple is lighter |
| Will you iterate over instances in tight loops (millions)? | NamedTuple for memory (16 bytes/instance) | Dataclass is negligible |
| Do you need to modify fields after creation? | Mutable dataclass | Use NamedTuple or frozen dataclass |
Practical Example: API Response Wrappers
Consider an API that returns either success data or an error. Here's when each shines:
# Immutable, hashable response status (NamedTuple is ideal)
from typing import NamedTuple
class ResponseMeta(NamedTuple):
status_code: int
timestamp: str
# Rich, mutable response body (dataclass fits better)
from dataclasses import dataclass
from typing import Generic, TypeVar
T = TypeVar('T')
@dataclass
class ApiResponse(Generic[T]):
meta: ResponseMeta
data: T | None = None
error: str | None = None
def is_success(self) -> bool:
return self.error is None
def raise_if_error(self) -> None:
if self.error:
raise RuntimeError(self.error)
Here, ResponseMeta is a NamedTuple (immutable, hashable, static) while the wrapper is a dataclass (flexible, with methods).
Key Takeaways
- NamedTuple is immutable, hashable, and memory-light; ideal for small, fixed-structure records.
- Dataclass is mutable, class-based, and supports methods and validation; ideal for rich domain objects.
- A frozen dataclass (
frozen=True) combines immutability and hashability with full dataclass features. - Use NamedTuple for API return values, config snapshots, and coordinates; use dataclass for stateful objects.
- Adding methods to NamedTuple is possible but less natural than with dataclasses.
Frequently Asked Questions
Can I convert between dataclass and NamedTuple?
Yes. Use dataclasses.asdict(instance) to get a dict, then reconstruct a NamedTuple. Or use the functional namedtuple(typename, fields) with unpacked dict: NT(**asdict(dc_instance)).
Which is faster, dataclass or NamedTuple?
NamedTuple has a slight edge (5–10%) in attribute access because it uses positional indexing rather than dict lookup. But the difference is negligible for most applications; profile your code if performance matters.
Can a frozen dataclass be used as a dict key?
Yes, frozen=True makes the dataclass hashable. However, all fields must also be hashable (strings, numbers, tuples are fine; lists and dicts are not).
Does Python's typing module provide NamedTuple?
Yes, since Python 3.6. Import it via from typing import NamedTuple (preferred) or from collections import namedtuple (older functional syntax).