Skip to main content

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

AspectDataclassNamedTuple
MutabilityMutable by defaultImmutable (always)
Memory overhead~56 bytes per instance (stores __dict__)~40 bytes (tuple-based, no __dict__)
SpeedComparable to hand-written classes5–10% faster attribute access (no dict lookup)
Inherits fromobject (regular class)tuple (immutable, tuple-like)
Tuple unpackingx, y = Point(1, 2) works but less naturalNatural: x, y = point
HashingUnhashable by default (mutable)Hashable (immutable)
MethodsCan add instance methods easilyCan add methods, but less idiomatic
Default valuesYes, via field(default=...)Yes, via assignment
Frozen modeYes, via frozen=TrueAlways frozen
Optional fieldsSupported; 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

QuestionIf YesIf No
Is the data inherently immutable?Use NamedTupleUse dataclass
Must it be hashable (dict key / set member)?Use NamedTuple or frozen=True dataclassDataclass is fine
Will you add methods or validation?Use dataclassNamedTuple 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 dataclassUse 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).

Further Reading