Skip to content

Frozen Dataclasses

The frozen=True parameter makes a dataclass immutable, preventing modifications after creation. Frozen dataclasses can be hashed and used in sets/dicts.


Creating Frozen Dataclasses

from dataclasses import dataclass

@dataclass(frozen=True)
class Point:
    x: float
    y: float

point = Point(1.0, 2.0)
print(point)  # Point(x=1.0, y=2.0)

# Attempt to modify raises FrozenInstanceError
try:
    point.x = 5.0
except AttributeError as e:
    print(f"Error: {e}")

Using Frozen Dataclasses as Dictionary Keys

from dataclasses import dataclass

@dataclass(frozen=True)
class Coordinate:
    x: int
    y: int

# Frozen dataclasses are hashable
coords = {
    Coordinate(0, 0): "origin",
    Coordinate(1, 1): "diagonal",
    Coordinate(-1, 1): "northwest"
}

print(coords[Coordinate(0, 0)])    # "origin"
print(Coordinate(1, 1) in coords)  # True

Using Frozen Dataclasses in Sets

from dataclasses import dataclass

@dataclass(frozen=True)
class Color:
    red: int
    green: int
    blue: int

colors = {
    Color(255, 0, 0),     # Red
    Color(0, 255, 0),     # Green
    Color(0, 0, 255),     # Blue
    Color(255, 0, 0)      # Duplicate red (ignored)
}

print(len(colors))  # 3 (unique colors only)
print(Color(255, 0, 0) in colors)  # True

Frozen vs Mutable

from dataclasses import dataclass

# Mutable (default)
@dataclass
class MutablePoint:
    x: float
    y: float

# Frozen
@dataclass(frozen=True)
class FrozenPoint:
    x: float
    y: float

# Mutable can be modified
mut_point = MutablePoint(1, 2)
mut_point.x = 3

# Frozen cannot
frozen_point = FrozenPoint(1, 2)
try:
    frozen_point.x = 3
except AttributeError as e:
    print(f"Cannot modify frozen: {e}")

# Frozen can be hashed (used as dict key)
point_map = {frozen_point: "initial"}
print(point_map[frozen_point])  # "initial"

Performance Implications

from dataclasses import dataclass
import timeit

@dataclass
class Mutable:
    x: int
    y: int

@dataclass(frozen=True)
class Frozen:
    x: int
    y: int

# Frozen dataclasses have hash cached
frozen = Frozen(1, 2)
print(hash(frozen))

# Creation time is similar
# But hashing is faster for frozen (cached)

When to Use Frozen

  • Use when data should be immutable (coordinates, colors, etc.)
  • Use when storing in sets or as dictionary keys
  • Use for function parameters that shouldn't be modified
  • Use for thread-safe data sharing

Runnable Example: dataclass_immutable_with_methods.py

"""
TUTORIAL: Frozen Dataclass with Custom Methods - Immutability and __str__
=========================================================================

In this tutorial, you'll learn how to create immutable dataclasses using the
frozen=True parameter. Immutable objects cannot be modified after creation,
which is useful for:

  - Data that should never change (like coordinates or dates)
  - Using instances as dictionary keys (requires immutability)
  - Thread-safe code where no synchronization is needed
  - Making intent clear: "this data is final"

We'll also override the __str__ method to create a custom string representation.
Unlike __repr__ (which shows the internal structure), __str__ shows a user-friendly
format. For geographic coordinates, we'll display them as "latitude degrees
direction, longitude degrees direction" format (e.g., "51.5°N, 0.1°W").

Why override __str__ in a frozen dataclass? Even though @dataclass generates
__repr__, you can provide a more readable __str__ for end users while keeping
the technical __repr__ for debugging.
"""

from dataclasses import dataclass


# ============ Example 1: Creating a Frozen Dataclass ============

if __name__ == "__main__":
    print("=" * 70)
    print("EXAMPLE 1: Defining a frozen (immutable) Coordinate dataclass")
    print("=" * 70)

    @dataclass(frozen=True)
    class Coordinate:
        """A geographic coordinate with latitude and longitude.

        The frozen=True parameter makes instances immutable. You cannot modify
        lat or lon after the Coordinate is created. Python will raise a
        FrozenInstanceError if you try.

        This class also overrides __str__ to display coordinates in a
        human-readable format (e.g., "51.5°N, 0.1°W").
        """
        lat: float
        lon: float

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

            We override __str__ to provide a geographic format:
            - Convert latitude to absolute value with N/S hemisphere indicator
            - Convert longitude to absolute value with E/W hemisphere indicator
            - Format with one decimal place for readability
            """
            # 'N' for positive latitude (North), 'S' for negative (South)
            ns = 'N' if self.lat >= 0 else 'S'
            # 'E' for positive longitude (East), 'W' for negative (West)
            we = 'E' if self.lon >= 0 else 'W'
            # Return formatted string with absolute values and direction indicators
            return f'{abs(self.lat):.1f}°{ns}, {abs(self.lon):.1f}°{we}'


    # Create some coordinate instances
    london = Coordinate(51.5074, -0.1278)
    sydney = Coordinate(-33.8688, 151.2093)
    equator = Coordinate(0.0, 0.0)

    print(f"\nCoordinates created:")
    print(f"  london = Coordinate(51.5074, -0.1278)")
    print(f"  sydney = Coordinate(-33.8688, 151.2093)")
    print(f"  equator = Coordinate(0.0, 0.0)")


    # ============ Example 2: __str__ vs __repr__ ============
    print("\n" + "=" * 70)
    print("EXAMPLE 2: Understanding __str__ vs __repr__")
    print("=" * 70)

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

    print(f"\nUsing repr() - dataclass-generated __repr__ (technical):")
    print(f"  repr(london) = {repr(london)}")
    print(f"  repr(sydney) = {repr(sydney)}")

    print(f"\nWhy both?")
    print(f"  - __str__(): For humans reading output")
    print(f"  - __repr__(): For developers debugging code")
    print(f"  - str() calls __str__() if it exists")
    print(f"  - repr() calls __repr__() and shows the 'official' representation")


    # ============ Example 3: Immutability - Trying to Modify ============
    print("\n" + "=" * 70)
    print("EXAMPLE 3: Testing immutability - frozen=True prevents changes")
    print("=" * 70)

    print(f"\nAttempting to modify a frozen dataclass:")
    print(f"  london.lat = 50.0  # Try to change latitude")

    try:
        london.lat = 50.0
        print(f"  ERROR: This should not succeed!")
    except Exception as e:
        print(f"  Result: {type(e).__name__}: {e}")
        print(f"  WHY? frozen=True makes the instance immutable")

    print(f"\nAttempting to add a new attribute:")
    print(f"  london.name = 'London'  # Try to add new attribute")

    try:
        london.name = 'London'
        print(f"  ERROR: This should not succeed!")
    except Exception as e:
        print(f"  Result: {type(e).__name__}: {e}")
        print(f"  WHY? frozen=True prevents any attribute modification")


    # ============ Example 4: Using Frozen Dataclasses as Dictionary Keys ============
    print("\n" + "=" * 70)
    print("EXAMPLE 4: Frozen dataclasses are hashable - use as dict keys")
    print("=" * 70)

    # Immutable objects can be used as dictionary keys
    print(f"\nCreating a dictionary with coordinates as keys:")
    cities = {
        london: 'London, UK',
        sydney: 'Sydney, Australia',
        equator: 'Null Island',
    }

    print(f"  cities = {{")
    for coord, name in cities.items():
        print(f"    {coord}: '{name}',")
    print(f"  }}")

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

    print(f"\nWhy is this only possible with frozen=True?")
    print(f"  - Dictionary keys must be hashable (immutable)")
    print(f"  - Mutable objects change after insertion, breaking the dictionary")
    print(f"  - frozen=True makes Coordinate instances hashable")


    # ============ Example 5: Hemisphere Direction Logic ============
    print("\n" + "=" * 70)
    print("EXAMPLE 5: Understanding the geographic direction logic")
    print("=" * 70)

    # Test coordinates in all four hemispheres
    north_east = Coordinate(45.0, 90.0)
    north_west = Coordinate(45.0, -90.0)
    south_east = Coordinate(-45.0, 90.0)
    south_west = Coordinate(-45.0, -90.0)

    print(f"\nCoordinates in all four hemispheres:")
    print(f"  North/East: lat=45.0, lon=90.0   →  {north_east}")
    print(f"  North/West: lat=45.0, lon=-90.0  →  {north_west}")
    print(f"  South/East: lat=-45.0, lon=90.0  →  {south_east}")
    print(f"  South/West: lat=-45.0, lon=-90.0 →  {south_west}")

    print(f"\nHow the __str__ method works:")
    print(f"  1. Check lat >= 0 → 'N' (North) or 'S' (South)")
    print(f"  2. Check lon >= 0 → 'E' (East) or 'W' (West)")
    print(f"  3. Use abs() to get positive values")
    print(f"  4. Format with .1f for one decimal place")


    # ============ Example 6: Summary of Frozen Dataclass Benefits ============
    print("\n" + "=" * 70)
    print("EXAMPLE 6: Key benefits of frozen dataclasses")
    print("=" * 70)

    print(f"\nFrozen dataclasses provide:")
    print(f"  1. Immutability: Values never change after creation")
    print(f"  2. Hashability: Can be used as dictionary keys or in sets")
    print(f"  3. Safety: Accidental modifications are caught as errors")
    print(f"  4. Intent clarity: Frozen = 'this data is final and safe'")

    print(f"\nCustom __str__ method provides:")
    print(f"  1. User-friendly output (geographic format, not raw numbers)")
    print(f"  2. Domain-specific representation")
    print(f"  3. Cleaner print() output for end users")

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