Python __slots__: Memory Optimization for Classes
Every Python object carries baggage. When you create a class instance, Python allocates a dictionary (`__dict__`) to store its attributes. This flexibility allows you to add attributes dynamically at...
Key Insights
- Python’s default
__dict__attribute storage consumes approximately 56 bytes per instance plus dictionary overhead, while__slots__reduces this to just the space needed for the defined attributes, saving 40-50% memory in typical scenarios. - Attribute access with
__slots__is 10-20% faster than dictionary lookups because Python uses direct offset access instead of hash table operations. - The memory savings compound dramatically with scale—a million instances of a simple class can see memory reduction from 400MB to 150MB, making
__slots__critical for data-intensive applications.
The Hidden Cost of Python Objects
Every Python object carries baggage. When you create a class instance, Python allocates a dictionary (__dict__) to store its attributes. This flexibility allows you to add attributes dynamically at runtime, but it comes at a steep price: memory overhead that becomes problematic when you’re working with thousands or millions of objects.
The __slots__ class attribute provides an alternative storage mechanism that trades dynamic flexibility for significant memory and performance gains. Let’s quantify the difference:
import sys
class RegularPoint:
def __init__(self, x, y):
self.x = x
self.y = y
class SlottedPoint:
__slots__ = ['x', 'y']
def __init__(self, x, y):
self.x = x
self.y = y
regular = RegularPoint(1.0, 2.0)
slotted = SlottedPoint(1.0, 2.0)
print(f"Regular instance: {sys.getsizeof(regular)} bytes")
print(f"Regular __dict__: {sys.getsizeof(regular.__dict__)} bytes")
print(f"Slotted instance: {sys.getsizeof(slotted)} bytes")
print(f"Total regular: {sys.getsizeof(regular) + sys.getsizeof(regular.__dict__)} bytes")
Output on a typical Python 3.11 installation:
Regular instance: 48 bytes
Regular __dict__: 104 bytes
Slotted instance: 48 bytes
Total regular: 152 bytes
The regular instance requires 152 bytes while the slotted version needs only 48 bytes—a 68% reduction for this simple two-attribute class.
Understanding Python’s Default Attribute Storage
Python’s default attribute storage uses a dictionary for maximum flexibility. This design choice aligns with Python’s philosophy of dynamic typing and runtime modification:
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
person = Person("Alice", 30)
print(person.__dict__) # {'name': 'Alice', 'age': 30}
# Dynamic attribute addition works seamlessly
person.email = "alice@example.com"
person.phone = "555-0100"
print(person.__dict__)
# {'name': 'Alice', 'age': 30, 'email': 'alice@example.com', 'phone': '555-0100'}
This flexibility is powerful for prototyping, metaprogramming, and plugin architectures. However, dictionaries in Python are implemented as hash tables, which require:
- Space for the hash table structure itself (minimum 104 bytes for an empty dict)
- Additional space for collision handling and load factor management
- Pointer indirection for each attribute access
When you’re creating millions of objects—think data processing pipelines, in-memory databases, or scientific computing—this overhead becomes untenable.
Implementing slots: Syntax and Behavior
Defining __slots__ is straightforward, but it fundamentally changes how your class behaves:
class WithoutSlots:
def __init__(self, x, y, z):
self.x = x
self.y = y
self.z = z
class WithSlots:
__slots__ = ['x', 'y', 'z']
def __init__(self, x, y, z):
self.x = x
self.y = y
self.z = z
# Regular class allows dynamic attributes
regular = WithoutSlots(1, 2, 3)
regular.new_attr = "works fine"
print(regular.new_attr) # works fine
# Slotted class restricts to declared attributes
slotted = WithSlots(1, 2, 3)
try:
slotted.new_attr = "will fail"
except AttributeError as e:
print(f"Error: {e}")
# Error: 'WithSlots' object has no attribute 'new_attr'
# __dict__ doesn't exist in slotted classes
print(hasattr(regular, '__dict__')) # True
print(hasattr(slotted, '__dict__')) # False
When you define __slots__, Python:
- Allocates a fixed-size array for the specified attributes
- Creates descriptors for each slot that map to array positions
- Omits the
__dict__and__weakref__attributes (unless explicitly included in slots) - Uses direct memory offsets for attribute access instead of dictionary lookups
Real-World Performance Benchmarks
Theory is useful, but let’s measure actual impact with a realistic scenario:
import tracemalloc
import timeit
from dataclasses import dataclass
class RegularRecord:
def __init__(self, id, name, value, timestamp):
self.id = id
self.name = name
self.value = value
self.timestamp = timestamp
class SlottedRecord:
__slots__ = ['id', 'name', 'value', 'timestamp']
def __init__(self, id, name, value, timestamp):
self.id = id
self.name = name
self.value = value
self.timestamp = timestamp
def benchmark_memory(cls, count=100000):
tracemalloc.start()
instances = [cls(i, f"name_{i}", i * 1.5, i * 1000) for i in range(count)]
current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
return peak / 1024 / 1024 # Convert to MB
def benchmark_access(instance, iterations=1000000):
setup = "from __main__ import instance"
stmt = "_ = instance.id; _ = instance.name; _ = instance.value"
return timeit.timeit(stmt, setup=setup, number=iterations)
# Memory benchmark
regular_mem = benchmark_memory(RegularRecord)
slotted_mem = benchmark_memory(SlottedRecord)
print(f"Regular class: {regular_mem:.2f} MB")
print(f"Slotted class: {slotted_mem:.2f} MB")
print(f"Memory saved: {(regular_mem - slotted_mem) / regular_mem * 100:.1f}%")
# Access speed benchmark
reg_instance = RegularRecord(1, "test", 1.5, 1000)
slot_instance = SlottedRecord(1, "test", 1.5, 1000)
reg_time = benchmark_access(reg_instance)
slot_time = benchmark_access(slot_instance)
print(f"\nAttribute access time:")
print(f"Regular: {reg_time:.4f}s")
print(f"Slotted: {slot_time:.4f}s")
print(f"Speed improvement: {(reg_time - slot_time) / reg_time * 100:.1f}%")
Typical results:
Regular class: 38.45 MB
Slotted class: 19.23 MB
Memory saved: 50.0%
Attribute access time:
Regular: 0.2847s
Slotted: 0.2398s
Speed improvement: 15.8%
Inheritance and slots Gotchas
Inheritance with __slots__ requires careful attention. The most common mistake is forgetting that slots aren’t inherited automatically:
# WRONG: Child loses slot benefits
class Parent:
__slots__ = ['x']
class ChildWrong(Parent):
def __init__(self, x, y):
self.x = x
self.y = y # This creates a __dict__!
# CORRECT: Explicitly define child slots
class ChildCorrect(Parent):
__slots__ = ['y'] # Only new attributes
def __init__(self, x, y):
self.x = x
self.y = y
# Verify behavior
wrong = ChildWrong(1, 2)
correct = ChildCorrect(1, 2)
print(f"ChildWrong has __dict__: {hasattr(wrong, '__dict__')}") # True
print(f"ChildCorrect has __dict__: {hasattr(correct, '__dict__')}") # False
For maximum memory efficiency with inheritance:
class Base:
__slots__ = [] # Empty slots prevent __dict__ in children
class Child(Base):
__slots__ = ['x', 'y', 'z']
def __init__(self, x, y, z):
self.x = x
self.y = y
self.z = z
If you need weak references or pickling support, explicitly include those slots:
class PicklableSlotted:
__slots__ = ['x', 'y', '__dict__', '__weakref__']
def __init__(self, x, y):
self.x = x
self.y = y
When to Use (and Avoid) slots
Use __slots__ when:
- You’re creating thousands or millions of instances
- Your classes have a fixed set of attributes
- Memory usage is a bottleneck
- You need predictable attribute access patterns
Here’s a practical example from a data processing pipeline:
class LogEntry:
__slots__ = ['timestamp', 'level', 'message', 'user_id', 'request_id']
def __init__(self, timestamp, level, message, user_id, request_id):
self.timestamp = timestamp
self.level = level
self.message = message
self.user_id = user_id
self.request_id = request_id
def process_logs(log_file, batch_size=1000000):
"""Process millions of log entries efficiently."""
entries = []
for line in log_file:
# Parse log line
entry = LogEntry(...) # Minimal memory footprint
entries.append(entry)
if len(entries) >= batch_size:
analyze_batch(entries)
entries.clear()
Avoid __slots__ when:
- You need dynamic attribute assignment
- You’re building plugin systems or frameworks
- The class is rarely instantiated
- You need
__dict__for serialization or introspection - You’re working with multiple inheritance (complex slot resolution)
Conclusion and Best Practices
The __slots__ optimization delivers substantial benefits for data-intensive Python applications, but it’s not a universal solution. Apply it strategically where the memory and performance gains justify the loss of flexibility.
Quick reference checklist:
- Profile first: Measure actual memory usage before optimizing
- List all attributes: Include every attribute in
__slots__, including__dict__if you need dynamic attributes - Handle inheritance: Define
__slots__in every class in the hierarchy - Consider weakref/pickle: Add
__weakref__to slots if needed - Document the choice: Comment why you’re using slots for future maintainers
- Test thoroughly: Ensure no code relies on
__dict__or dynamic attributes
For classes instantiated millions of times, __slots__ can reduce memory consumption by 40-60% and improve attribute access speed by 10-20%. In data processing, scientific computing, and high-performance applications, these gains translate directly to handling larger datasets and faster execution times.