Skip to content

Dataclass vs NamedTuple vs attrs

Python offers multiple ways to create simple classes. Understanding differences helps you choose the right tool.


Dataclasses

from dataclasses import dataclass

@dataclass
class PersonDataclass:
    name: str
    age: int
    city: str = "Unknown"

person = PersonDataclass("Alice", 30)
person.age = 31  # Mutable
print(person)    # PersonDataclass(name='Alice', age=31, city='Unknown')

NamedTuple

from typing import NamedTuple

class PersonNamedTuple(NamedTuple):
    name: str
    age: int
    city: str = "Unknown"

person = PersonNamedTuple("Bob", 25)
print(person)       # PersonNamedTuple(name='Bob', age=25, city='Unknown')
print(person[0])    # 'Bob' (tuple indexing works)
# person.age = 26   # Error: immutable

attrs Library

# pip install attrs
import attrs

@attrs.define
class PersonAttrs:
    name: str
    age: int
    city: str = "Unknown"

person = PersonAttrs("Charlie", 28)
person.age = 29  # Mutable
print(person)    # PersonAttrs(name='Charlie', age=29, city='Unknown')

Comparison

from dataclasses import dataclass
from typing import NamedTuple

@dataclass
class DataclassPerson:
    name: str
    age: int = 0

class NamedTuplePerson(NamedTuple):
    name: str
    age: int = 0

# Mutability
dc_person = DataclassPerson("Alice")
dc_person.age = 30  # Works

nt_person = NamedTuplePerson("Bob")
# nt_person.age = 30  # Error

# Tuple unpacking (NamedTuple only)
name, age = nt_person
print(f"{name}: {age}")

# Hashing
dc_frozen = DataclassPerson("Charlie")
dc_dict = {dc_frozen: "value"}  # Error without frozen=True

nt_dict = {nt_person: "value"}  # Works automatically

Performance Comparison

import timeit
from dataclasses import dataclass
from typing import NamedTuple

@dataclass
class DC:
    x: int
    y: int

class NT(NamedTuple):
    x: int
    y: int

# Creation time similar
# NamedTuple slightly faster for creation
# Dataclass more flexible

time_dc = timeit.timeit(lambda: DC(1, 2), number=100000)
time_nt = timeit.timeit(lambda: NT(1, 2), number=100000)

print(f"Dataclass: {time_dc:.4f}s")
print(f"NamedTuple: {time_nt:.4f}s")

When to Use Each

Dataclasses: - Mutable objects - Complex initialization - Need many methods - Standard library preferred

NamedTuple: - Immutable records - Tuple unpacking needed - Dictionary/set keys - Lightweight - Type hints integration

attrs: - Complex validation - Custom init behavior - Slots by default (memory efficient) - Not in standard library


Runnable Example: namedtuple_basic_example.py

"""
TUTORIAL: NamedTuple Basics - Typed Named Tuples vs Dataclasses
================================================================

In this tutorial, you'll learn about NamedTuple, Python's typed alternative
to the regular tuple. NamedTuple provides:

  - Named fields for accessing data (e.g., person.name instead of person[0])
  - Type hints for clarity and IDE support
  - Immutability (tuples cannot be modified after creation)
  - Unpacking capability (like regular tuples)
  - Smaller memory footprint than dataclasses

The main difference from @dataclass:
  - NamedTuple is immutable by default (dataclass is mutable by default)
  - NamedTuple is a tuple subclass (dataclass is not)
  - NamedTuple has smaller memory overhead
  - Dataclass offers more flexibility with the frozen= parameter

In this file, we show the basic NamedTuple syntax and compare it to the
equivalent dataclass version. Notice the three types of attributes:
  1. Typed fields with no default: REQUIRED
  2. Typed fields with defaults: OPTIONAL
  3. Class attributes without type hints: NOT fields
"""

import typing


# ============ Example 1: Basic NamedTuple Definition ============

if __name__ == "__main__":
    print("=" * 70)
    print("EXAMPLE 1: Defining a basic NamedTuple")
    print("=" * 70)

    class DemoNTClass(typing.NamedTuple):
        """A NamedTuple with three attributes following the same pattern as dataclass.

        Attributes:
            a (int): Required field - must provide when creating instances.
                     Has type hint, so it's a named tuple field.

            b (float): Optional field with default value.
                       If not provided, instances will use 1.1.
                       Has type hint, so it's a named tuple field.

            c: Class attribute without type hint.
               Not treated as a field, unlike 'a' and 'b'.
        """
        # Field 1: Required - has type hint, no default
        a: int           # <1> Required when creating instance

        # Field 2: Optional - has type hint and default value
        b: float = 1.1   # <2> Optional, defaults to 1.1

        # Not a field - no type hint, so treated as class attribute
        c = 'spam'       # <3> Class attribute, not field


    print(f"\nNamedTuple definition complete.\n")


    # ============ Example 2: Creating NamedTuple Instances ============
    print("=" * 70)
    print("EXAMPLE 2: Creating instances and accessing fields")
    print("=" * 70)

    # Create with required field only
    instance1 = DemoNTClass(42)
    print(f"\ninstance1 = DemoNTClass(42)")
    print(f"  instance1.a = {instance1.a}")
    print(f"  instance1.b = {instance1.b}  (uses default)")
    print(f"  Accessing by name: instance1.a (more readable than instance1[0])")

    # Create with both fields
    instance2 = DemoNTClass(100, 2.5)
    print(f"\ninstance2 = DemoNTClass(100, 2.5)")
    print(f"  instance2.a = {instance2.a}")
    print(f"  instance2.b = {instance2.b}")

    # Create using keyword arguments
    instance3 = DemoNTClass(a=50, b=1.5)
    print(f"\ninstance3 = DemoNTClass(a=50, b=1.5)  (using keyword arguments)")
    print(f"  instance3.a = {instance3.a}")
    print(f"  instance3.b = {instance3.b}")


    # ============ Example 3: NamedTuple vs Regular Tuple ============
    print("\n" + "=" * 70)
    print("EXAMPLE 3: NamedTuple advantages over regular tuple")
    print("=" * 70)

    # Regular tuple - unclear what each value represents
    regular_tuple = (100, 2.5)
    print(f"\nRegular tuple:")
    print(f"  my_tuple = (100, 2.5)")
    print(f"  my_tuple[0] = {regular_tuple[0]}  (What is this? Need documentation)")
    print(f"  my_tuple[1] = {regular_tuple[1]}  (What is this? Need documentation)")

    # NamedTuple - clear field names
    named_tuple = DemoNTClass(100, 2.5)
    print(f"\nNamedTuple:")
    print(f"  my_tuple = DemoNTClass(100, 2.5)")
    print(f"  my_tuple.a = {named_tuple.a}  (Clear: this is 'a')")
    print(f"  my_tuple.b = {named_tuple.b}  (Clear: this is 'b')")

    print(f"\nWhy NamedTuple is better:")
    print(f"  1. Names make code self-documenting")
    print(f"  2. IDE autocomplete works with named fields")
    print(f"  3. Type hints help catch errors before runtime")
    print(f"  4. Still has tuple efficiency and immutability")


    # ============ Example 4: Immutability - NamedTuples Cannot Be Modified ============
    print("\n" + "=" * 70)
    print("EXAMPLE 4: NamedTuples are immutable by default")
    print("=" * 70)

    print(f"\nAttempting to modify a NamedTuple:")
    print(f"  instance1.a = 50  # Try to change field 'a'")

    try:
        instance1.a = 50
        print(f"  ERROR: This should not succeed!")
    except AttributeError as e:
        print(f"  Result: AttributeError: {e}")
        print(f"  WHY? NamedTuples are immutable (like regular tuples)")

    print(f"\nThis is a key difference from mutable dataclasses:")
    print(f"  - NamedTuple: Immutable by default (like tuple)")
    print(f"  - Dataclass: Mutable by default (can use frozen=True)")


    # ============ Example 5: Tuple Operations Still Work ============
    print("\n" + "=" * 70)
    print("EXAMPLE 5: NamedTuple still supports tuple operations")
    print("=" * 70)

    # Indexing
    print(f"\nIndexing (like a regular tuple):")
    print(f"  instance2[0] = {instance2[0]}  (first field: a)")
    print(f"  instance2[1] = {instance2[1]}  (second field: b)")

    # Unpacking
    print(f"\nUnpacking (like a regular tuple):")
    a_val, b_val = instance2
    print(f"  a_val, b_val = instance2")
    print(f"  a_val = {a_val}, b_val = {b_val}")

    # Iteration
    print(f"\nIteration (like a regular tuple):")
    print(f"  for value in instance2:")
    for value in instance2:
        print(f"    {value}")

    # Length
    print(f"\nLength:")
    print(f"  len(instance2) = {len(instance2)}")

    # String representation
    print(f"\nString representation:")
    print(f"  str(instance2) = {str(instance2)}")
    print(f"  repr(instance2) = {repr(instance2)}")


    # ============ Example 6: Class Attributes in NamedTuple ============
    print("\n" + "=" * 70)
    print("EXAMPLE 6: Class attributes without type hints")
    print("=" * 70)

    print(f"\nThe 'c' attribute is a class attribute (like in dataclass):")
    print(f"  DemoNTClass.c = '{DemoNTClass.c}'")
    print(f"  instance1.c = '{instance1.c}'")
    print(f"  instance2.c = '{instance2.c}'")

    print(f"\nIt's shared across all instances:")
    print(f"  (Accessing it through instances shows the class-level value)")

    print(f"\nIt's NOT in the NamedTuple fields:")
    print(f"  len(instance2) = {len(instance2)}  (only a and b, not c)")


    # ============ Example 7: NamedTuple vs Dataclass Comparison ============
    print("\n" + "=" * 70)
    print("EXAMPLE 7: NamedTuple vs Dataclass")
    print("=" * 70)

    print(f"\nNamedTuple advantages:")
    print(f"  1. Immutable by default (safer for dict keys/sets)")
    print(f"  2. Lighter memory footprint (still a tuple)")
    print(f"  3. Compatible with any tuple operation")
    print(f"  4. Great for simple data structures")

    print(f"\nDataclass advantages:")
    print(f"  1. Mutable by default (easier to work with)")
    print(f"  2. More flexible (add methods, use field())")
    print(f"  3. order=True generates comparison methods")
    print(f"  4. Better for complex data structures")

    print(f"\nChoose NamedTuple when:")
    print(f"  - You need immutability")
    print(f"  - You want tuple-like behavior")
    print(f"  - Memory efficiency matters")
    print(f"  - Your data is simple (few fields, no methods)")

    print(f"\nChoose Dataclass when:")
    print(f"  - You need mutability")
    print(f"  - You want to add methods to your data structure")
    print(f"  - You need customizable initialization or comparison")
    print(f"  - You need field validation")

    print(f"\n" + "=" * 70)

Runnable Example: namedtuple_typed_with_methods.py

"""
TUTORIAL: Typed NamedTuple with Custom Methods - Adding Behavior to Tuples
===========================================================================

In this tutorial, you'll learn how to add custom methods to NamedTuple classes.
NamedTuple is immutable by default, but you can override methods like __str__
to customize how instances are displayed.

Key insight: NamedTuple is both a tuple AND a class. You can:
  - Keep immutability (for safety and hashability)
  - Add custom methods (for behavior and presentation)
  - Use all tuple operations (indexing, unpacking, iteration)

In this example, we override __str__ to display geographic coordinates in
human-readable format (e.g., "51.5°N, 0.1°W") while keeping the technical
__repr__ for debugging.

Why add methods to NamedTuple?
  - Custom __str__ for user-friendly output
  - Custom __repr__ for debugging information
  - Helper methods for common operations on the data
  - Validation or transformation methods
"""

from typing import NamedTuple


# ============ Example 1: NamedTuple with Custom __str__ ============

if __name__ == "__main__":
    print("=" * 70)
    print("EXAMPLE 1: Defining a NamedTuple with custom __str__ method")
    print("=" * 70)

    class Coordinate(NamedTuple):
        """A geographic coordinate with latitude and longitude.

        This NamedTuple extends the basic tuple with:
        - Named fields for clarity (lat, lon instead of [0], [1])
        - Type hints for documentation and IDE support
        - Custom __str__ for readable geographic format
        - Immutability for safety and hashability

        The __str__ method converts:
          (51.5074, -0.1278) → "51.5°N, 0.1°W"
        """
        lat: float
        lon: float

        def __str__(self):
            """Return a user-friendly string representation of the coordinate.

            This converts raw coordinates to geographic format:
            - 'N' for positive latitude (North), 'S' for negative (South)
            - 'E' for positive longitude (East), 'W' for negative (West)
            - One decimal place for readability

            Returns:
                str: Coordinate in "latitude°direction, longitude°direction" format
            """
            # Determine hemisphere indicators
            ns = 'N' if self.lat >= 0 else 'S'  # North or South
            we = 'E' if self.lon >= 0 else 'W'  # East or West

            # Format with absolute values and one decimal place
            return f'{abs(self.lat):.1f}°{ns}, {abs(self.lon):.1f}°{we}'


    # Create coordinate instances
    london = Coordinate(51.5074, -0.1278)
    sydney = Coordinate(-33.8688, 151.2093)
    tokyo = Coordinate(35.6762, 139.6503)

    print(f"\nCoordinates created:")
    print(f"  london = Coordinate(51.5074, -0.1278)")
    print(f"  sydney = Coordinate(-33.8688, 151.2093)")
    print(f"  tokyo = Coordinate(35.6762, 139.6503)")


    # ============ Example 2: __str__ vs __repr__ ============
    print("\n" + "=" * 70)
    print("EXAMPLE 2: Comparing __str__ (user-friendly) vs __repr__ (technical)")
    print("=" * 70)

    print(f"\nUsing str() - our custom __str__ method:")
    print(f"  str(london) = {str(london)}")
    print(f"  str(sydney) = {str(sydney)}")
    print(f"  str(tokyo) = {str(tokyo)}")

    print(f"\nUsing repr() - automatic NamedTuple __repr__:")
    print(f"  repr(london) = {repr(london)}")
    print(f"  repr(sydney) = {repr(sydney)}")

    print(f"\nWhy both?")
    print(f"  - __str__(): For humans (e.g., print output, user interfaces)")
    print(f"  - __repr__(): For developers (e.g., debugging, interactive shell)")
    print(f"  - print() calls __str__() if it exists, otherwise __repr__()")


    # ============ Example 3: Using in print Statements ============
    print("\n" + "=" * 70)
    print("EXAMPLE 3: Using coordinates in print statements")
    print("=" * 70)

    print(f"\nSimple print statements use __str__:")
    print(f"  print(london) outputs: {london}")
    print(f"  print(sydney) outputs: {sydney}")

    print(f"\nYou can format them in strings:")
    message = f"Meeting in London at {london}"
    print(f"  message = f'Meeting in London at {{london}}'")
    print(f"  Result: {message}")

    locations = [london, sydney, tokyo]
    print(f"\nPrinting a list of locations:")
    for name, loc in [('London', london), ('Sydney', sydney), ('Tokyo', tokyo)]:
        print(f"  {name}: {loc}")


    # ============ Example 4: NamedTuple Operations Still Work ============
    print("\n" + "=" * 70)
    print("EXAMPLE 4: NamedTuple is still a tuple - indexing and unpacking work")
    print("=" * 70)

    print(f"\nAccessing by field name (more readable):")
    print(f"  london.lat = {london.lat}")
    print(f"  london.lon = {london.lon}")

    print(f"\nAccessing by index (like regular tuple):")
    print(f"  london[0] = {london[0]}  (latitude)")
    print(f"  london[1] = {london[1]}  (longitude)")

    print(f"\nUnpacking:")
    lat, lon = london
    print(f"  lat, lon = london")
    print(f"  lat = {lat}, lon = {lon}")

    print(f"\nIteration:")
    print(f"  for value in london:")
    for value in london:
        print(f"    {value}")

    print(f"\nLength:")
    print(f"  len(london) = {len(london)}")


    # ============ Example 5: Immutability ============
    print("\n" + "=" * 70)
    print("EXAMPLE 5: NamedTuple instances are immutable")
    print("=" * 70)

    print(f"\nAttempting to modify: london.lat = 50.0")
    try:
        london.lat = 50.0
        print(f"  ERROR: This should have failed!")
    except AttributeError as e:
        print(f"  Result: AttributeError")
        print(f"  WHY? NamedTuple is immutable (inherits from tuple)")

    print(f"\nAttempting to add new attribute: london.city = 'London'")
    try:
        london.city = 'London'
        print(f"  ERROR: This should have failed!")
    except AttributeError as e:
        print(f"  Result: AttributeError")
        print(f"  WHY? Tuples don't support arbitrary attribute assignment")


    # ============ Example 6: Using as Dictionary Keys ============
    print("\n" + "=" * 70)
    print("EXAMPLE 6: Hashable - can use as dictionary keys")
    print("=" * 70)

    # Create a dictionary with coordinates as keys
    cities = {
        london: 'London, UK',
        sydney: 'Sydney, Australia',
        tokyo: 'Tokyo, Japan',
    }

    print(f"\nUsing coordinates as dictionary keys:")
    print(f"  cities = {{")
    for coord, name in cities.items():
        print(f"    {coord}: '{name}',")
    print(f"  }}")

    print(f"\nLooking up cities:")
    print(f"  cities[london] = '{cities[london]}'")
    print(f"  cities[sydney] = '{cities[sydney]}'")

    print(f"\nWhy is this possible?")
    print(f"  - Dictionary keys must be hashable (immutable)")
    print(f"  - NamedTuple is immutable by default")
    print(f"  - Both __str__ and immutability make it a perfect key type")


    # ============ Example 7: Adding More Methods ============
    print("\n" + "=" * 70)
    print("EXAMPLE 7: Adding additional helper methods")
    print("=" * 70)

    class EnhancedCoordinate(NamedTuple):
        """Coordinate with additional helper methods."""
        lat: float
        lon: float

        def __str__(self):
            """User-friendly geographic format."""
            ns = 'N' if self.lat >= 0 else 'S'
            we = 'E' if self.lon >= 0 else 'W'
            return f'{abs(self.lat):.1f}°{ns}, {abs(self.lon):.1f}°{we}'

        def is_northern_hemisphere(self):
            """Check if coordinate is in Northern Hemisphere."""
            return self.lat > 0

        def is_equator(self):
            """Check if coordinate is on the equator."""
            return abs(self.lat) < 0.01  # Within 0.01 degrees

        def distance_from_prime_meridian(self):
            """Calculate absolute distance from prime meridian (0° longitude)."""
            return abs(self.lon)


    # Use the enhanced coordinate
    location = EnhancedCoordinate(51.5, 0.1)
    print(f"\nlocation = EnhancedCoordinate(51.5, 0.1)")
    print(f"  str(location) = {str(location)}")
    print(f"  location.is_northern_hemisphere() = {location.is_northern_hemisphere()}")
    print(f"  location.is_equator() = {location.is_equator()}")
    print(f"  location.distance_from_prime_meridian() = {location.distance_from_prime_meridian()}")

    equator_loc = EnhancedCoordinate(0.0, 30.0)
    print(f"\nequator_loc = EnhancedCoordinate(0.0, 30.0)")
    print(f"  str(equator_loc) = {str(equator_loc)}")
    print(f"  equator_loc.is_equator() = {equator_loc.is_equator()}")
    print(f"  equator_loc.is_northern_hemisphere() = {equator_loc.is_northern_hemisphere()}")


    # ============ Example 8: Summary - NamedTuple with Methods ============
    print("\n" + "=" * 70)
    print("EXAMPLE 8: Key benefits of NamedTuple with methods")
    print("=" * 70)

    print(f"\nNamedTuple combines the best of both worlds:")
    print(f"  1. Tuple benefits:")
    print(f"     - Immutable and hashable")
    print(f"     - Lightweight (lower memory than dataclass)")
    print(f"     - Can use in sets and as dict keys")
    print(f"     - Compatible with tuple unpacking/indexing")

    print(f"  2. Class benefits:")
    print(f"     - Named fields (self.lat instead of self[0])")
    print(f"     - Type hints for clarity and IDE support")
    print(f"     - Custom methods (__str__, __repr__, helpers)")
    print(f"     - Self-documenting code")

    print(f"\nWhen to use:")
    print(f"  - Small, immutable data structures")
    print(f"  - Need tuple-like operations (unpacking, iteration)")
    print(f"  - Want hashability (dict keys, set members)")
    print(f"  - Performance matters (lighter than dataclass)")

    print(f"\nWhen NOT to use:")
    print(f"  - Need mutability")
    print(f"  - Building complex objects with many methods")
    print(f"  - Want inheritance (NamedTuple has limitations)")

    print(f"\n" + "=" * 70)