Blog7 min12/4/2025

Python: Slotted dataclasses + cached_property — when to use them (with benchmarks)

#Tip of the Day#Python Tricks#Python Developer#Python for AI#Coding Best Practices#Rian Infotech

Practical guidance on slotted dataclasses + cached_property: a snippet-ready summary, runnable micro-benchmarks, and important caveats for slots.

Rishav Shankar

Rishav Shankar

Share this article

Python: Slotted dataclasses + cached_property — when to use them (with benchmarks)

In modern AI development, the battle for performance is waged on two fronts: memory and compute. Whether you're processing large datasets, managing complex data objects for RAG pipelines, or maintaining state for AI agents, every byte of memory and every CPU cycle counts. Mastering Python's deep performance features is no longer a "nice-to-have"—it's a non-negotiable skill for building scalable, responsive, and cost-effective AI systems. This tip introduces a powerful, three-in-one optimization pattern that delivers a trifecta of benefits: combining @dataclass with slots=True and @cached_property.

1. Explanation of the Tip: The Performance Trifecta

Before diving into the code, it's essential to understand the three core Python features that work in concert to make this optimization pattern so effective. By deconstructing each component, you can appreciate how they solve distinct performance challenges, and why their combination is more than the sum of its parts.

  • @dataclass: The Foundation Introduced in Python 3.7, the dataclass decorator automates the generation of boilerplate methods like __init__(), __repr__(), and __eq__(). Its primary role is to simplify the creation of classes that are mainly used for storing data, making your code cleaner and more maintainable.

  • slots=True: The Memory Optimizer Available for dataclasses since Python 3.10, the slots=True argument is a game-changer for memory efficiency. By default, Python classes store instance attributes in a dynamic dictionary called __dict__. This provides flexibility but consumes significant memory. When you enable slots, Python replaces the __dict__ with a fixed-size, array-like structure. This simple change yields impressive results:

    • ~40-50% reduction in memory usage per instance.

    • ~35% faster attribute access.

  • @cached_property: The Lazy Compute Engine Part of the functools module, the @cached_property decorator transforms a method into a property whose result is computed once and then cached for the lifetime of the instance. It's the ultimate tool for "lazy computation"—deferring an expensive operation until it's absolutely needed. The first time the property is accessed, the method runs. On every subsequent access, the cached value is returned instantly, preventing redundant work.

Seeing these three concepts work together in a single class makes their combined power immediately clear.

2. Full Code Example

Here is a clean, executable example demonstrating the pattern. The DataProcessor class is designed to be memory-light while deferring expensive statistical calculations and hashing operations.

from dataclasses import dataclass
from functools import cached_property
from typing import Any

@dataclass(slots=True)
class DataProcessor:
    """Memory-efficient data processor with lazy computation."""
    dataset: list[int]

    @cached_property
    def computed_stats(self) -> dict[str, float]:
        """Expensive computation cached on first access."""
        return {
            "mean": sum(self.dataset) / len(self.dataset),
            "max": max(self.dataset),
            "min": min(self.dataset),
        }

    @cached_property
    def data_hash(self) -> str:
        """Another expensive operation, cached separately."""
        return str(hash(tuple(self.dataset)))

# Creating an instance is memory-efficient due to slots=True
processor = DataProcessor(dataset=list(range(1000000)))

# The 'computed_stats' method runs ONLY on this first call.
print(processor.computed_stats)

# This call is instantaneous, returning the cached value.
print(processor.computed_stats)

# The 'data_hash' method runs here, as it's the first access.
print(processor.data_hash)

3. Deep Dive Breakdown

Now, let's move beyond the "what" to explore the "how." This section analyzes the internal mechanics of the pattern, revealing how Python handles memory and attribute access under the hood to deliver its performance benefits.

  • @dataclass(slots=True): At compile time, this decorator combination instructs Python to generate the standard dataclass methods but to build the class using __slots__ instead of __dict__. This pre-defines the instance attributes (dataset in our example) in a fixed structure. The result is a more compact object in memory, as there's no overhead from a dictionary for each instance.

  • @cached_property: This decorator is implemented as a non-data descriptor. Here’s how it works:

    1. When you first access a cached property (e.g., processor.computed_stats), Python's attribute lookup mechanism invokes the descriptor's __get__ method.

    2. The descriptor executes the decorated function (e.g., the code that calculates the mean, max, and min).

    3. It then stores the computed value in the corresponding slot on the instance, replacing the descriptor object for that instance. The dataclass implementation handles this descriptor management correctly.

    4. All future accesses to processor.computed_stats will find the computed value directly and return it, completely bypassing the descriptor and the original function.

  • Runtime Complexity: The performance characteristics are exceptional. The initial access to a cached property has the time complexity of the computation itself. However, all subsequent accesses are O(1), or constant time, as they are simple attribute lookups.

Understanding these mechanics allows you to apply the pattern with confidence in a variety of real-world scenarios.

4. Real-World Applications for AI Developers

This pattern isn't just a theoretical curiosity; it's a practical tool for solving common problems in AI and data engineering. Here are several high-value use cases.

  • Feature Engineering Pipelines Imagine a class representing a data sample, like an image or a text document. Store the raw data, and define complex features (e.g., image embeddings, sentiment scores, named entities) as cached properties. This ensures the expensive feature extraction only runs if a specific ML model in your pipeline actually requires that feature.

  • RAG Document Chunking In a Retrieval-Augmented Generation (RAG) system, you might have an object that holds a retrieved text chunk. The raw text is stored, but properties like summary, embedding_vector, or keyword_list can be lazily computed. This saves significant processing time during initial retrieval and filtering, as you only compute embeddings or summaries for the chunks that pass a certain relevance threshold.

  • AI Agent Tool Representation Represent a tool available to an LLM agent as a slotted dataclass. Basic information like the tool's name and description can be stored directly. A property like tool.api_schema, which might require a network call to a remote service, can be a cached property that only fetches the schema the first time the agent considers using that specific tool. This pattern assumes the tool's schema is static for the instance's lifetime; if the schema can change dynamically, this caching strategy would need to be re-evaluated.

  • Stream Processing Events For a class that parses events from a high-throughput data stream (e.g., IoT sensor data or financial tickers), store the raw event payload. Statistical aggregations or anomaly scores can be implemented as cached properties, computed only when an event is flagged by a downstream rule for deeper analysis.

To make this more tangible, let's build a mini-project based on one of these ideas.

5. Mini-Project Example: A Lazy Log Analyzer

A common production challenge is analyzing millions of structured log entries (e.g., JSON strings) without incurring massive upfront memory and CPU costs. Our goal is to build a LogEntry class that is memory-efficient and intelligently defers the expensive parsing of the JSON payload until it's actually needed.

import json
from dataclasses import dataclass
from functools import cached_property
from typing import Any, Optional

@dataclass(slots=True)
class LogEntry:
    """A memory-efficient log entry that lazily parses its JSON payload."""
    raw_log: str
    timestamp: float

    @cached_property
    def parsed_data(self) -> Optional[dict[str, Any]]:
        """Parses the JSON payload from the raw log string on first access."""
        try:
            # Assume the JSON payload is after the first '{'
            json_start = self.raw_log.find('{')
            if json_start != -1:
                return json.loads(self.raw_log[json_start:])
        except json.JSONDecodeError:
            return None # Handle malformed JSON gracefully
        return None

    @cached_property
    def is_error(self) -> bool:
        """Checks the log level from the parsed data, also on first access."""
        if self.parsed_data and self.parsed_data.get("level") == "ERROR":
            return True
        return False

# Simulate a large number of logs
log_lines = [
    "1672531201.123 INFO User logged in {'user_id': 'abc', 'source': 'web'}",
    "1672531202.456 ERROR Payment failed {'tx_id': 'xyz', 'reason': 'insufficient_funds'}",
    "1672531203.789 INFO Request processed {'request_id': '123', 'status': 200}"
] * 100_000

# Creating thousands of LogEntry objects is memory-light due to __slots__.
# No JSON parsing has occurred yet.
log_objects = [LogEntry(raw_log=line, timestamp=float(line.split()[0])) for line in log_lines]

# Now, we only parse the logs that we want to inspect.
error_logs = [log for log in log_objects if log.is_error]

# The .is_error access triggered .parsed_data for each log.
# Now, accessing .parsed_data again for the error logs is instant.
for error in error_logs:
    print(f"Error Reason: {error.parsed_data.get('reason')}")

This design elegantly solves the problem. Instantiating thousands or hundreds of thousands of LogEntry objects is fast and memory-light because of __slots__. The expensive json.loads() operation is deferred and only runs for the specific log entries whose properties (.parsed_data or .is_error) are actually inspected, making the overall analysis significantly more performant.

6. When Developers Should Use This Pattern

Knowing when to apply a pattern is just as important as knowing how. Here’s a guide based on experience level.

For Beginners

First, focus on mastering standard Python classes and basic dataclasses. When you write a script that creates many objects in a loop and notice it's slow or consumes a lot of memory, this pattern is your next step. It's a perfect introduction to performance-aware programming.

For Intermediate Developers

Start using this pattern proactively in data processing scripts, backend services, or API response models. It's especially valuable anywhere object instantiation is high and some attributes are computationally expensive. Think of classes representing database rows where some fields require extra queries or calculations.

For Advanced Developers

For those building performance-critical libraries, SDKs, or high-throughput systems, this should be a default pattern. Any system where memory footprint and computational latency are key architectural concerns is a prime candidate. Use profiling tools to identify hot spots in your code where objects are frequently created and have expensive properties—these are ideal candidates for this optimization. Be aware that slotted classes do not permit adding new attributes at runtime, enforcing a rigid structure that is beneficial for performance but less flexible for dynamic experimentation.

This pattern is a powerful tool, and it belongs to a broader family of optimization techniques available in Python.

7. Related Python Techniques

Mastering one performance pattern opens the door to others. Here are a few related tools from Python's standard library that solve similar problems.

  • functools.lru_cache: A decorator to memoize (cache) the results of functions, ideal for expensive, pure functions that are called repeatedly with the same arguments.

  • collections.namedtuple: A factory function for creating tuple subclasses with named fields, offering an even more lightweight and immutable alternative to dataclasses for very simple data structures.

  • The attrs Library: A popular third-party library that served as the inspiration for dataclasses. It provides a more powerful and configurable alternative, offering features like validators and converters out of the box.

Closing Insights

The combination of slotted dataclasses with cached properties is a prime example of Python's pragmatic depth. It provides a clean, readable, and powerful solution to a common set of performance challenges. Mastering fundamental, performance-oriented features like these is what separates good developers from great ones, especially in the demanding field of AI, where efficiency directly translates to greater capability and lower operational costs.

TL;DR — When to use slotted dataclasses + cached_property

Use slotted dataclasses when you create many fixed-shape objects and need lower memory plus faster attribute access; add cached properties for expensive, read-mostly values you compute once. In slots-only classes, prefer a manual cached @property (or add a __dict__) because functools.cached_property stores its cache in the instance dict.

from dataclasses import dataclass, field

@dataclass(slots=True)
class Sample:
    data: list[int]
    _total: int | None = field(init=False, default=None, repr=False)

    @property
    def total(self) -> int:
        if self._total is None:            # one-time compute, then cache
            object.__setattr__(self, "_total", sum(self.data))
        return self._total

Micro-benchmarks: slots vs non-slots (run locally)

This script compares memory (tracemalloc) and creation/access times (timeit) for dataclasses with and without slots, and shows first vs repeat access of a cached property. Results vary by machine, Python version, workload, and OS; run locally to see your numbers.

import gc, timeit, tracemalloc
from dataclasses import dataclass, field
from functools import cached_property

N = 50_000  # adjust for your machine

# A regular dataclass (has __dict__), works with functools.cached_property
@dataclass
class NoSlots:
    x: int
    data: list[int] = field(default_factory=list)

    @cached_property
    def total(self) -> int:
        # expensive once, instant thereafter
        return sum(self.data)

# A slotted dataclass (no __dict__ by default)
@dataclass(slots=True)
class WithSlots:
    x: int
    data: list[int] = field(default_factory=list)

# --- Memory: create many instances and compare peak bytes ---

def mem_for(factory):
    gc.collect()
    tracemalloc.start()
    objs = factory()
    current, peak = tracemalloc.get_traced_memory()
    tracemalloc.stop()
    return peak, len(objs)

peak_no, count_no = mem_for(lambda: [NoSlots(i, [i, i+1, i+2]) for i in range(N)])
peak_sl, count_sl = mem_for(lambda: [WithSlots(i, [i, i+1, i+2]) for i in range(N)])

print({
    "NoSlots_peak_bytes": peak_no,
    "WithSlots_peak_bytes": peak_sl,
    "count": count_no,
})

# --- Time: construction cost ---
create_no = timeit.timeit("[NoSlots(i, [i, i+1, i+2]) for i in range(N)]", globals=globals(), number=1)
create_sl = timeit.timeit("[WithSlots(i, [i, i+1, i+2]) for i in range(N)]", globals=globals(), number=1)
print({"create_NoSlots_s": create_no, "create_WithSlots_s": create_sl})

# --- Time: cached_property first vs repeat access (NoSlots) ---
obj = NoSlots(1, list(range(100_000)))
first = timeit.timeit("obj.total", globals=locals(), number=1)
repeat = timeit.timeit("obj.total", globals=locals(), number=100_000)
print({"cached_property_first_s": first, "cached_property_repeat_100k_s": repeat})

Interpretation notes: expect lower memory per instance with slots and constant-time repeat access for cached properties. If you need cached properties on slots-only classes, see the gotchas below.

Gotchas: cached_property with slots

  • Stdlib caching needs __dict__: functools.cached_property stores its cache in the instance __dict__. A slots-only class (no __dict__) will raise when caching.
  • Workarounds:
    • Add a dict: define __slots__ manually and include "__dict__" (or avoid slots=True), then use @cached_property.
    • Keep slots-only and implement a manual cached @property that writes to a private slot (see the TL;DR example).
    • Or use a third‑party cached_property implementation that supports slots.
  • Invalidation: Cached values won’t auto‑refresh. If the underlying data changes, clear or reset the cache explicitly.
  • Concurrency: cached_property isn’t atomic; consider a lock if multiple threads may compute the value simultaneously.

Frequently Asked Questions

Not by default. functools.cached_property caches into the instance __dict__, so a slots-only class (no __dict__) will fail. Options: give the class a __dict__ by defining __slots__ manually and including "__dict__", use a manual cached @property that writes to a private slot, or use a third‑party cached_property that supports slots.
Rishav Shankar
About the Author

Rishav Shankar

Rishav Shankar is a calm-tech architect who blends AI, engineering, and psychology to design systems that think before they act. He builds products that turn complex human problems into intuitive digital experiences, redefining how founders and teams operate. At the intersection of automation, strategy, and imagination, Rishav is creating the future one intelligent workflow at a time.

Comments

Loading...

Leave a Comment

Minimum 10 characters required

0 / 2000