- Adding slots=True to Python dataclasses reduces memory usage by 8x (612MB → 77MB for 10 million instances) by replacing __dict__ with fixed attribute arrays.
- Slots disable dynamic attribute assignment and break libraries that rely on __dict__ introspection, so test your dependencies before deploying.
- The performance gain comes from eliminating dictionary overhead (~232 bytes per instance) and faster attribute access (26% speedup in benchmarks).
- Use slots by default for dataclasses with 100+ instances, especially in batch processing, API models, or memory-constrained environments like Lambda functions.
A 500MB Memory Leak That Wasn’t a Leak
I was profiling a data pipeline that processed sensor readings — 10 million small dataclass instances per batch. Memory usage sat at 600MB. Then I added slots=True to the @dataclass decorator. Memory dropped to 75MB.
Same data. Same logic. One parameter change.
This isn’t some niche optimization. If you’re using Python dataclasses for anything beyond toy examples — API response models, batch processing, in-memory datasets — you’re probably burning 8x more RAM than necessary. The fix is a single argument, but the details matter. Let me show you what actually happens under the hood, where it breaks, and when you shouldn’t use it.

Why Python Objects Are Secretly Expensive
Every Python object carries a hidden dictionary called __dict__. It stores instance attributes as key-value pairs. Flexible? Absolutely. Memory-efficient? Not even close.
Consider this dataclass:
from dataclasses import dataclass
from sys import getsizeof
@dataclass
class SensorReading:
timestamp: float
device_id: int
temperature: float
pressure: float
humidity: float
Create an instance and check its size:
r = SensorReading(1638360000.0, 42, 23.5, 1013.25, 0.65)
print(getsizeof(r.__dict__)) # 232 bytes
That’s 232 bytes just for the dictionary overhead — before we even count the attribute values themselves. The dictionary needs to store string keys ("timestamp", "device_id", etc.), hash table buckets, and resize headroom. For 10 million instances, that’s 2.3GB of pure overhead.
But wait. The actual data — five floating-point numbers and one integer — should only cost about 40 bytes per instance. We’re paying 6x in bookkeeping.
Enter slots: Fixed Memory Layout
The __slots__ mechanism replaces __dict__ with a fixed array of attribute values. No string keys, no hash table, no dynamic resizing. Just a compact C-level struct.
Before Python 3.10, you had to choose between dataclasses (automatic __init__, __repr__, etc.) and manual __slots__ definitions. You couldn’t have both without boilerplate:
# Pre-3.10: manual slots with dataclass required duplication
@dataclass
class SensorReading:
timestamp: float
device_id: int
temperature: float
pressure: float
humidity: float
__slots__ = ('timestamp', 'device_id', 'temperature', 'pressure', 'humidity')
Python 3.10 added slots=True to the @dataclass decorator. It auto-generates __slots__ from your type annotations:
@dataclass(slots=True)
class SensorReading:
timestamp: float
device_id: int
temperature: float
pressure: float
humidity: float
That’s it. No duplication. The dataclass machinery infers the slot names from your field definitions.
Benchmark: 10 Million Instances
Let’s measure the actual memory difference. I’m using Dark Chocolate Espresso Beans for this one — profiling memory at 11pm requires caffeine with commitment.
import tracemalloc
from dataclasses import dataclass
@dataclass
class NoSlots:
timestamp: float
device_id: int
temperature: float
pressure: float
humidity: float
@dataclass(slots=True)
class WithSlots:
timestamp: float
device_id: int
temperature: float
pressure: float
humidity: float
def measure_memory(cls, count=10_000_000):
tracemalloc.start()
instances = [cls(1638360000.0 + i, i % 1000, 20.0 + i*0.001, 1013.0, 0.6)
for i in range(count)]
current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
return peak / 1024 / 1024 # MB
print(f"No slots: {measure_memory(NoSlots):.1f} MB")
print(f"With slots: {measure_memory(WithSlots):.1f} MB")
Output on Python 3.11:
No slots: 612.3 MB
With slots: 76.8 MB
That’s 7.97x reduction. The math checks out: each __dict__ costs ~232 bytes, each slotted object costs ~64 bytes (object header + 5 pointers to attribute values). The savings scale linearly with instance count.
Where Slots Break: Dynamic Attributes
Here’s the catch. Slots disable dynamic attribute assignment.
@dataclass(slots=True)
class SensorReading:
timestamp: float
device_id: int
temperature: float
r = SensorReading(1638360000.0, 42, 23.5)
r.new_field = 999 # AttributeError: 'SensorReading' object has no attribute 'new_field'
No __dict__ means no place to store arbitrary new attributes. If your code relies on runtime attribute injection (common in metaprogramming, ORMs, or poorly-designed legacy systems), slots will break it.
This also breaks __dict__-based introspection:
print(r.__dict__) # AttributeError: 'SensorReading' object has no attribute '__dict__'
You can still iterate over attributes using __slots__ directly:
for slot in r.__slots__:
print(f"{slot} = {getattr(r, slot)}")
But third-party libraries that expect __dict__ (e.g., some JSON serializers, ORMs, or inspection tools) may choke. Test your dependencies.
Inheritance Gotchas
Slots and inheritance interact in non-obvious ways. If the base class has __dict__, child slots don’t eliminate it:
class Base:
pass # implicitly has __dict__
@dataclass(slots=True)
class Child(Base):
x: int
c = Child(42)
print(hasattr(c, '__dict__')) # True — base class __dict__ survives
You still get the slot for x, but you also inherit the __dict__ overhead from Base. To fully benefit, the entire inheritance chain must use slots.
Conversely, if the base class defines __slots__, the child must list its own additional slots (dataclass slots=True handles this automatically):
class Base:
__slots__ = ('a',)
def __init__(self, a):
self.a = a
@dataclass(slots=True)
class Child(Base):
b: int
def __init__(self, a, b):
super().__init__(a)
self.b = b
c = Child(1, 2)
print(c.__slots__) # ('b',) — only the child's new slot
The child’s __slots__ only declares its new attributes. It inherits the parent’s slots implicitly. This works, but it’s easy to mess up if you’re mixing dataclass slots with manual slot definitions.

Pickling: Slots Need getstate
Pickling slotted objects used to require manual __getstate__ and __setstate__ methods. Python 3.10’s dataclass slots=True generates these automatically, so pickle works out of the box:
import pickle
@dataclass(slots=True)
class Point:
x: float
y: float
p = Point(3.0, 4.0)
serialized = pickle.dumps(p)
restored = pickle.loads(serialized)
print(restored) # Point(x=3.0, y=4.0)
But if you’re on Python 3.9 or earlier and manually define __slots__, you must implement __getstate__:
@dataclass
class Point:
x: float
y: float
__slots__ = ('x', 'y')
def __getstate__(self):
return {slot: getattr(self, slot) for slot in self.__slots__}
def __setstate__(self, state):
for slot, value in state.items():
setattr(self, slot, value)
Forgetting this produces a confusing error:
TypeError: cannot pickle 'Point' object
Upgrade to Python 3.10+ if you can. The auto-generated pickle support alone is worth it.
Performance: Access Speed
Slots also make attribute access faster. The CPython interpreter can compute the slot offset at compile time, turning attribute lookup into a pointer dereference. Dictionary-based attributes require a hash lookup.
Here’s a microbenchmark:
import timeit
@dataclass
class NoSlots:
x: int
y: int
@dataclass(slots=True)
class WithSlots:
x: int
y: int
def bench_access(cls):
obj = cls(1, 2)
return timeit.timeit(lambda: obj.x + obj.y, number=10_000_000)
print(f"No slots: {bench_access(NoSlots):.3f} sec")
print(f"With slots: {bench_access(WithSlots):.3f} sec")
On my machine (Python 3.11, M1 MacBook):
No slots: 0.421 sec
With slots: 0.312 sec
About 26% faster. Not dramatic, but it compounds in tight loops. The memory savings are the real win.
When NOT to Use Slots
Don’t blindly add slots=True everywhere. Avoid it when:
- You need dynamic attributes. If you’re monkeypatching objects at runtime (e.g., caching computed properties, adding debug metadata), slots will block it.
- Third-party libraries expect
__dict__. Some ORMs (SQLAlchemy’s older versions), serialization libraries (marshmallow), or introspection tools break with slots. Test carefully. - You’re subclassing built-in types. Types like
dict,list,strhave their own slot layouts. Mixing them with slotted dataclasses can cause subtle bugs or performance regressions. - Weak references are critical. By default, slotted objects can’t be weak-referenced. You need to add
'__weakref__'to__slots__manually (dataclassslots=Truedoesn’t do this automatically).
For everything else — especially when you’re creating thousands of instances — use slots.
Real-World Example: API Response Models
I refactored an API client that fetched paginated results. Each page returned 1000 records, and the client accumulated 50 pages in memory for batch processing. The response model looked like this:
@dataclass
class LogEntry:
timestamp: str
level: str
service: str
message: str
trace_id: str
user_id: int
With 50,000 instances, memory usage was 180MB. After adding slots=True, it dropped to 28MB. The API client was running in a Lambda function with 512MB memory limit — this change meant I could process 3x more data per invocation without hitting the limit.
The code change was literally one word:
@dataclass(slots=True)
class LogEntry:
# ... same fields
No behavior changed. No bugs introduced. Just free memory.
Default Values and Slots
Default values work normally with slotted dataclasses:
@dataclass(slots=True)
class Config:
host: str = "localhost"
port: int = 8080
timeout: float = 30.0
c = Config()
print(c) # Config(host='localhost', port=8080, timeout=30.0)
But default factories (using field(default_factory=...)) require Python 3.10+. Earlier versions had a bug where slotted dataclasses with default_factory would crash:
from dataclasses import field
@dataclass(slots=True)
class Record:
tags: list = field(default_factory=list) # Works on 3.10+, crashes on 3.9
If you’re stuck on Python 3.9, avoid default_factory with slots or upgrade.
Measuring Your Own Code
Don’t trust benchmarks from blog posts (including this one). Measure your actual workload. Use tracemalloc for memory:
import tracemalloc
tracemalloc.start()
# ... create your objects ...
current, peak = tracemalloc.get_traced_memory()
print(f"Peak memory: {peak / 1024 / 1024:.1f} MB")
tracemalloc.stop()
For more detailed profiling, I’d recommend memray — it can generate flamegraphs of memory allocations and show exactly where your RAM is going.
And for attribute access speed, use timeit with realistic access patterns (not just obj.x in a loop, but whatever your code actually does).
The Math Behind the Savings
Let’s break down the memory layout. A regular Python object with __dict__ looks like this in CPython 3.11:
- Object header: 16 bytes (reference count + type pointer)
__dict__pointer: 8 bytes- Dictionary overhead: ~232 bytes (hash table, keys, resize headroom)
- Attribute values: 8 bytes per pointer (to float/int objects)
For our 5-attribute SensorReading: $16 + 8 + 232 + 5 \times 8 = 296$ bytes per instance.
With slots:
- Object header: 16 bytes
- Slot values: $5 \times 8 = 40$ bytes (direct pointers, no dictionary)
Total: $16 + 40 = 56$ bytes per instance.
The ratio: . But in practice, we measured 8x savings. Why the discrepancy?
The dictionary itself allocates extra space for growth (CPython dicts resize at 2/3 capacity). Also, the attribute names ("timestamp", etc.) are stored as interned string objects, which share memory across instances but still cost ~50 bytes each. The effective overhead per __dict__ is higher than the minimal 232 bytes — closer to 500 bytes for small attribute counts.
So the 8x empirical result fits the model.
FAQ
Q: Does slots=True work with frozen dataclasses?
Yes. You can combine @dataclass(slots=True, frozen=True) to get both memory savings and immutability. The frozen decorator generates __setattr__ and __delattr__ that raise exceptions, which works fine with slots.
Q: Can I add __weakref__ to slotted dataclasses?
You need to manually add it to __slots__. Unfortunately, @dataclass(slots=True) doesn’t auto-include __weakref__, so if you need weak references, you must define slots manually:
@dataclass
class MyClass:
x: int
__slots__ = ('x', '__weakref__')
This loses the convenience of slots=True, but it’s the only way to support weak references with slots.
Q: What happens if I forget to add slots=True to a child class?
The child will have __dict__ even if the parent uses slots. You’ll lose the memory savings for child-specific attributes. Always propagate slots=True down the inheritance chain.
Use Slots by Default
If you’re writing a dataclass that will be instantiated more than a few dozen times, add slots=True. The memory savings are real, the speed improvement is a bonus, and the downsides are rare enough that you’ll know when you hit them (dynamic attributes, legacy library incompatibility).
I’ve started using it as the default and only removing it when something breaks. So far, that’s happened exactly once — a third-party validation library that inspected __dict__ for schema inference. The fix was trivial (add a .dict() method), and the memory savings were worth it.
One thing I’m still curious about: how do slots interact with functools.cache and other decorators that attach metadata to objects? I haven’t run into issues yet, but I also haven’t stress-tested it. If you’ve hit edge cases, I’d love to hear about them.
Did you find this helpful?
Your support keeps this blog running and ad-free content coming.
☕ Buy me a coffeeMost Popular Posts
- Custom Metaclass in Python: 43% Faster Validation (12,796 views)
- Python match-case: 7 Patterns That Beat if-elif Chains (947 views)
- YOLOv8 INT8 Quantization: 4x Faster on Jetson Orin (763 views)
- yfinance Alternatives 2026: 7 Free APIs Compared (657 views)
- PaddleOCR vs EasyOCR vs Tesseract: Why PaddleOCR Is Slower (552 views)