Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 36

`NamedTuple` vs. `@dataclass`: Choosing the Right Data Structure

Recommended resources

Introduction

Welcome back. In our last lesson, you mastered the @dataclass decorator, learning how to create robust data-holding classes that automatically handle boilerplate like __init__, __repr__, and __eq__. You also learned to enforce immutability with frozen=True and perform validation using __post_init__, significantly cleaning up code that might otherwise be verbose and error-prone.

Now that you have @dataclass as a powerful tool for creating flexible objects, the next logical question is: is it always the right choice? When you just need a simple, immutable container for data, Python offers an even more lightweight and efficient alternative.

This lesson directly addresses the learning outcome: Choose between NamedTuple (for immutable, tuple-like public APIs) and @dataclass (for flexible, mutable objects). We will dissect the trade-offs between typing.NamedTuple and @dataclass across several critical axes: mutability, behavior, performance, and memory usage. By the end, you will have a clear mental model for deciding which tool is appropriate for the job.


What is typing.NamedTuple?

Before we compare, let's establish what a NamedTuple is. It's a data structure that combines the memory efficiency and immutability of a tuple with the readability of a class. It allows you to access elements by name (like point.x) instead of only by index (point[0]), without the overhead of a full class object.

The modern way to define one is by using typing.NamedTuple.

from typing import NamedTuple

class Point(NamedTuple):
    x: float
    y: float




# Instantiation is like a class
p1 = Point(10.0, 20.5)




# Access attributes by name
print(f"Coordinates: ({p1.x}, {p1.y})")




# It's still a tuple, so you can unpack it
x_coord, y_coord = p1
print(f"Unpacked: x={x_coord}, y={y_coord}")




# And access by index
print(f"Access by index: p1[0] = {p1[0]}")




# It's immutable
try:
    p1.x = 15.0
except AttributeError as e:
    print(f"Error on modification: {e}")

Notice the key characteristics: it's created like a class, provides named access, but fundamentally behaves like an immutable tuple.


NamedTuple vs. @dataclass: The Core Trade-offs

The choice between NamedTuple and @dataclass is a classic Python design decision. It's not about which is "better" overall, but which is optimal for a specific task. Let's break down the comparison.

Write Pythonic and Clean Code With namedtuple

The following article from Real Python provides an excellent, in-depth comparison of namedtuple and @dataclass. It includes performance and memory benchmarks that make the theoretical differences concrete. This is the primary reading for this lesson.

Read the section titled 'namedtuple vs Data Classes'. Pay close attention to the discussions on mutability (including @dataclass(frozen=True)), memory consumption, and performance. The code examples and benchmark results are particularly important.

The article you just read provides a detailed analysis. Let's synthesize its findings and add more context in a direct comparison table.

Feature typing.NamedTuple @dataclass
Mutability Immutable. Always. Mutable by default. Can be made immutable with @dataclass(frozen=True).
Behavior Data only. Cannot have custom methods. Data + Behavior. It's a regular class; you can add any methods you need.
Memory Usage Low. It's a tuple, so it has no instance __dict__. Very memory-efficient. Higher. Uses an instance __dict__ by default, which consumes more memory.
Performance Fast. Instance creation is typically faster than a dataclass. Slightly slower instance creation due to the overhead of a full object.
Inheritance Limited. You can't add new instance attributes through inheritance. Full support for standard class inheritance.
Features Minimalist. Provides named fields and immutability. That's it. Feature-rich. Supports __post_init__, default factories, field-level customization, etc.

To see a visual breakdown of the performance and memory differences, the following video provides clear benchmarks.

Which Python @dataclass is best? Feat. Pydantic, NamedTuple, attrs...

This video from the mCoding channel offers a visual and quantitative comparison that complements the Real Python article. Focus on the graphs showing creation time and memory usage.

Watch the segments from 02:08 to 04:06 and 06:35 to 09:16. The first part discusses the nature of tuple-based containers. The second part presents performance graphs that clearly show the memory and speed advantages of NamedTuple over dataclasses.


A Clear Decision Framework

Based on these trade-offs, we can establish a straightforward decision-making process.

When to use typing.NamedTuple

Choose NamedTuple when your primary goal is to create a simple, immutable record type. It's the perfect choice if all of the following are true:

  • The object will never be modified after creation.
  • The object will not have any custom behavior (i.e., no methods).
  • Memory efficiency and creation speed are important (e.g., creating millions of instances to parse a large file).
  • You want the object to behave like a tuple (e.g., for easy unpacking).

A classic use case is returning multiple, well-defined values from a function, where a plain tuple would be ambiguous.




# Good: NamedTuple for a simple, immutable return value
class ParseResult(NamedTuple):
    record_id: int
    status: str
    data: dict

def parse_log_line(line: str) -> ParseResult:



    # ... parsing logic ...
    return ParseResult(record_id=123, status="OK", data={"user": "admin"})

When to use @dataclass

Choose @dataclass when you need more than just a simple data record. It's the right tool if any of the following apply:

  • The object's state might need to change after creation (mutability is required).
  • The object needs to have custom methods or logic associated with its data.
  • You need advanced features like default values for mutable fields (default_factory) or post-creation validation (__post_init__).
  • You plan to use inheritance to build more complex objects.

The Special Case: @dataclass(frozen=True)

What if you need an object that is immutable but also needs custom methods? This is where NamedTuple falls short, and @dataclass(frozen=True) shines.

Consider a Coordinate object. We want it to be immutable, but we also want a handy method to calculate its distance to another point.

from dataclasses import dataclass
import math




# Use @dataclass(frozen=True) for an immutable object with methods.
@dataclass(frozen=True)
class Coordinate:
    lat: float
    lon: float

    def distance_to(self, other: 'Coordinate') -> float:



        # Simplified distance calculation for example purposes
        return math.sqrt((self.lat - other.lat)**2 + (self.lon - other.lon)**2)




# Now we have the best of both worlds:
# 1. Immutability
# 2. Custom behavior
p1 = Coordinate(34.05, -118.24) # Los Angeles
p2 = Coordinate(40.71, -74.00)  # New York

print(f"Distance: {p1.distance_to(p2):.2f} degrees")



# Output: Distance: 44.31 degrees




# It's immutable
try:
    p1.lat = 0
except Exception as e:
    print(f"Error: {e.__class__.__name__}")



    # Output: Error: FrozenInstanceError

This makes @dataclass(frozen=True) the go-to choice for creating value objects that encapsulate both data and related logic in an immutable package.


Exercise: Refactor a Data Structure

Your task is to apply this decision framework. You'll start with one implementation and refactor it as requirements change.

Part 1: Simple, Immutable Log Entries

You are processing firewall logs. Each entry is a simple record of an event that will never change once parsed. You will be holding millions of these in memory for analysis, so memory efficiency is critical.

Task: Implement a LogEntry structure for this purpose. It should contain:

  • timestamp: str
  • source_ip: str
  • event: str

Choose the most appropriate tool (NamedTuple or @dataclass) and implement it.

Part 2: Adding Behavior

New requirements have come in. You now need to add a method to the LogEntry structure called is_suspicious(). This method will return True if the event is 'Port scan detected'. The log entries must remain immutable.

Task: Refactor your LogEntry implementation to accommodate this new requirement. Choose the most appropriate tool for this new scenario.

Provide your code for both parts.

Solution

Part 1 Solution

For simple, immutable, memory-sensitive records with no behavior, typing.NamedTuple is the ideal choice.

from typing import NamedTuple




# Part 1: Using NamedTuple for a lightweight, immutable record
class LogEntry(NamedTuple):
    timestamp: str
    source_ip: str
    event: str




# Verification
entry1 = LogEntry(
    timestamp="2023-10-27T10:00:00Z",
    source_ip="192.168.1.101",
    event="Login success"
)
print(f"Part 1 - Log Entry: {entry1}")
print(f"Part 1 - Is tuple? {isinstance(entry1, tuple)}")




# It is immutable
try:
    entry1.source_ip = "1.1.1.1"
except AttributeError as e:
    print(f"Part 1 - Caught expected error: {e}")

Part 2 Solution

The requirements are now for an immutable object that has behavior (a method). This is the perfect use case for @dataclass(frozen=True).

from dataclasses import dataclass




# Part 2: Refactoring to @dataclass(frozen=True) to add methods
@dataclass(frozen=True)
class LogEntry:
    timestamp: str
    source_ip: str
    event: str

    def is_suspicious(self) -> bool:
        """Returns True if the event indicates a potential threat."""
        return self.event == 'Port scan detected'




# Verification
entry2 = LogEntry(
    timestamp="2023-10-27T10:05:00Z",
    source_ip="10.0.0.5",
    event="Login failed"
)
entry3 = LogEntry(
    timestamp="2023-10-27T10:06:00Z",
    source_ip="203.0.113.12",
    event="Port scan detected"
)

print(f"\nPart 2 - Log Entry 2: {entry2}")
print(f"Part 2 - Is entry 2 suspicious? {entry2.is_suspicious()}") # False

print(f"Part 2 - Log Entry 3: {entry3}")
print(f"Part 2 - Is entry 3 suspicious? {entry3.is_suspicious()}") # True




# It remains immutable
try:
    entry3.event = "Something else"
except Exception as e:
    print(f"Part 2 - Caught expected error: {e.__class__.__name__}")


Conclusion

You have now analyzed the key differences between typing.NamedTuple and @dataclass and have a clear framework for choosing between them. This is a fundamental skill for designing clean and efficient Python applications.

Key Takeaways:

  • typing.NamedTuple is for simple, immutable data records. Choose it for its low memory footprint and speed when you don't need custom methods. It's perfect for public APIs that return structured, tuple-like data.
  • @dataclass is for flexible objects that may have state and behavior. Choose it when you need mutability, custom methods, inheritance, or advanced features like __post_init__.
  • @dataclass(frozen=True) fills the gap for immutable objects that require behavior. It provides the data integrity of NamedTuple with the flexibility of a full class.

In the next lesson, we will explore another way to handle structured data: modern dictionary idioms. While NamedTuple and @dataclass provide formal structure, dictionaries offer a more dynamic and flexible approach. You will learn idiomatic patterns for accessing, manipulating, and merging dictionaries safely and efficiently.