Python Slots vs Dict: Performance Comparison
By default, Python stores object attributes in a dictionary accessible via `__dict__`. This provides maximum flexibility—you can add, remove, or modify attributes at runtime. However, this...
Key Insights
__slots__reduces memory usage by 40-50% per instance by replacing the instance dictionary with a fixed-size array, making it essential for applications creating millions of objects- Attribute access with
__slots__is 10-20% faster than dictionary-based attributes due to direct memory offset calculations instead of hash table lookups - The performance benefits come at the cost of flexibility:
__slots__prevents dynamic attribute assignment and complicates inheritance, making it suitable only for stable, well-defined data structures
Introduction to Python Object Attributes
By default, Python stores object attributes in a dictionary accessible via __dict__. This provides maximum flexibility—you can add, remove, or modify attributes at runtime. However, this flexibility comes with significant memory overhead and performance costs.
The __slots__ mechanism offers an alternative: you declare a fixed set of attributes at class definition time, and Python stores them in a more efficient structure. Here’s the fundamental difference:
# Traditional dict-based class
class PersonDict:
def __init__(self, name, age, email):
self.name = name
self.age = age
self.email = email
# Slots-based class
class PersonSlots:
__slots__ = ('name', 'age', 'email')
def __init__(self, name, age, email):
self.name = name
self.age = age
self.email = email
# Usage is identical
p1 = PersonDict("Alice", 30, "alice@example.com")
p2 = PersonSlots("Bob", 25, "bob@example.com")
# But internal storage differs drastically
print(hasattr(p1, '__dict__')) # True
print(hasattr(p2, '__dict__')) # False
The dict-based instance has a full dictionary object, while the slots-based instance uses a more compact representation.
How slots Works Under the Hood
When you define __slots__, Python creates descriptors for each attribute name and stores instance data in a fixed-size array rather than a hash table. Each descriptor knows its offset in this array, enabling direct memory access.
Here’s what happens internally:
import sys
class WithDict:
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
# Create instances
d = WithDict(1, 2, 3)
s = WithSlots(1, 2, 3)
# Memory size comparison
print(f"Dict-based instance: {sys.getsizeof(d)} bytes")
print(f"Dict-based __dict__: {sys.getsizeof(d.__dict__)} bytes")
print(f"Total dict-based: {sys.getsizeof(d) + sys.getsizeof(d.__dict__)} bytes")
print(f"\nSlots-based instance: {sys.getsizeof(s)} bytes")
# Examine the slot descriptors
print(f"\nSlot descriptor for 'x': {type(WithSlots.x)}")
The slots version eliminates the dictionary entirely. Attribute access becomes a simple offset calculation rather than a hash lookup, which explains both the memory savings and speed improvements.
Memory Consumption Benchmarks
The real benefit of __slots__ emerges when creating many instances. Let’s measure the difference with a realistic scenario:
import tracemalloc
import sys
class PointDict:
def __init__(self, x, y, z):
self.x = x
self.y = y
self.z = z
class PointSlots:
__slots__ = ('x', 'y', 'z')
def __init__(self, x, y, z):
self.x = x
self.y = y
self.z = z
def measure_memory(point_class, count=1_000_000):
tracemalloc.start()
points = [point_class(i, i+1, i+2) for i in range(count)]
current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
return current / 1024 / 1024 # Convert to MB
# Measure memory usage
dict_memory = measure_memory(PointDict)
slots_memory = measure_memory(PointSlots)
print(f"Dict-based: {dict_memory:.2f} MB")
print(f"Slots-based: {slots_memory:.2f} MB")
print(f"Savings: {((dict_memory - slots_memory) / dict_memory * 100):.1f}%")
# Per-instance comparison
d = PointDict(1, 2, 3)
s = PointSlots(1, 2, 3)
print(f"\nPer instance - Dict: {sys.getsizeof(d) + sys.getsizeof(d.__dict__)} bytes")
print(f"Per instance - Slots: {sys.getsizeof(s)} bytes")
In typical scenarios, you’ll see 40-50% memory reduction. For a million Point objects, this translates to hundreds of megabytes saved. The savings scale linearly with instance count, making __slots__ crucial for data-intensive applications.
Performance Benchmarks: Attribute Access Speed
Beyond memory, __slots__ provides faster attribute access. Let’s measure the difference:
import timeit
class DataDict:
def __init__(self, a, b, c, d, e):
self.a = a
self.b = b
self.c = c
self.d = d
self.e = e
class DataSlots:
__slots__ = ('a', 'b', 'c', 'd', 'e')
def __init__(self, a, b, c, d, e):
self.a = a
self.b = b
self.c = c
self.d = d
self.e = e
# Setup
dict_obj = DataDict(1, 2, 3, 4, 5)
slots_obj = DataSlots(1, 2, 3, 4, 5)
# Benchmark attribute reads
dict_read = timeit.timeit('obj.a + obj.b + obj.c',
globals={'obj': dict_obj},
number=10_000_000)
slots_read = timeit.timeit('obj.a + obj.b + obj.c',
globals={'obj': slots_obj},
number=10_000_000)
print(f"Dict read time: {dict_read:.4f}s")
print(f"Slots read time: {slots_read:.4f}s")
print(f"Speedup: {(dict_read / slots_read - 1) * 100:.1f}%")
# Benchmark attribute writes
dict_write = timeit.timeit('obj.a = 10; obj.b = 20',
globals={'obj': dict_obj},
number=10_000_000)
slots_write = timeit.timeit('obj.a = 10; obj.b = 20',
globals={'obj': slots_obj},
number=10_000_000)
print(f"\nDict write time: {dict_write:.4f}s")
print(f"Slots write time: {slots_write:.4f}s")
print(f"Speedup: {(dict_write / slots_write - 1) * 100:.1f}%")
# Benchmark instance creation
dict_create = timeit.timeit('DataDict(1, 2, 3, 4, 5)',
globals={'DataDict': DataDict},
number=1_000_000)
slots_create = timeit.timeit('DataSlots(1, 2, 3, 4, 5)',
globals={'DataSlots': DataSlots},
number=1_000_000)
print(f"\nDict creation time: {dict_create:.4f}s")
print(f"Slots creation time: {slots_create:.4f}s")
print(f"Speedup: {(dict_create / slots_create - 1) * 100:.1f}%")
Typical results show 10-20% faster attribute access and 15-25% faster instance creation. While not dramatic for single operations, these gains compound in tight loops or high-throughput systems.
Limitations and Trade-offs
The __slots__ mechanism imposes significant restrictions that make it unsuitable for many use cases:
# Limitation 1: No dynamic attributes
class RestrictedSlots:
__slots__ = ('x', 'y')
def __init__(self, x, y):
self.x = x
self.y = y
obj = RestrictedSlots(1, 2)
# obj.z = 3 # AttributeError: 'RestrictedSlots' object has no attribute 'z'
# Workaround: Include __dict__ in slots (loses some memory benefit)
class FlexibleSlots:
__slots__ = ('x', 'y', '__dict__')
def __init__(self, x, y):
self.x = x
self.y = y
obj2 = FlexibleSlots(1, 2)
obj2.z = 3 # Works now
# Limitation 2: Inheritance complications
class Base:
__slots__ = ('a',)
class Derived(Base):
__slots__ = ('b',) # Must define new slots
# If you forget __slots__, you get __dict__ back
# Limitation 3: No weak references by default
class NoWeakRef:
__slots__ = ('x',)
# To enable weak references:
class WithWeakRef:
__slots__ = ('x', '__weakref__')
# Limitation 4: Multiple inheritance issues
class A:
__slots__ = ('a',)
class B:
__slots__ = ('b',)
# class C(A, B): # TypeError: multiple bases have instance lay-out conflict
# pass
# Workaround: One base must have empty slots
class BEmpty:
__slots__ = ()
class C(A, BEmpty): # Works
__slots__ = ('c',)
These limitations mean __slots__ works best for stable, well-defined data structures where the attribute set is known upfront and won’t change.
Real-World Use Cases and Recommendations
Use __slots__ when:
- Creating many instances (thousands to millions) where memory matters
- Building data transfer objects with fixed schemas
- Implementing performance-critical data structures
Here’s a practical example—a 3D vector class used in a physics simulation:
class Vector3D:
__slots__ = ('x', 'y', 'z')
def __init__(self, x=0.0, y=0.0, z=0.0):
self.x = x
self.y = y
self.z = z
def magnitude(self):
return (self.x**2 + self.y**2 + self.z**2) ** 0.5
def __add__(self, other):
return Vector3D(
self.x + other.x,
self.y + other.y,
self.z + other.z
)
def __repr__(self):
return f"Vector3D({self.x}, {self.y}, {self.z})"
# Simulating 100,000 particles
particles = [Vector3D(i, i+1, i+2) for i in range(100_000)]
Stick with dict-based classes when:
- Flexibility is needed for dynamic attributes
- Working with small instance counts where memory isn’t critical
- Building extensible frameworks where users might add attributes
- Dealing with complex inheritance hierarchies
Conclusion and Best Practices
The choice between __slots__ and dictionary-based attributes depends on your specific requirements:
Choose __slots__ if:
- You’re creating 10,000+ instances
- Memory consumption is a concern
- Attributes are well-defined and won’t change
- You need maximum performance for attribute access
Stick with __dict__ if:
- You need dynamic attribute assignment
- Instance count is low (< 1,000)
- Code flexibility and maintainability matter more than performance
- You’re using complex inheritance or mixins
Don’t prematurely optimize. Profile your application first. If memory or attribute access shows up as a bottleneck and you’re creating many instances of a class, __slots__ is a powerful tool. Otherwise, the flexibility of standard Python objects is usually worth the overhead.
When you do use __slots__, document it clearly—other developers need to know they can’t add arbitrary attributes. Consider providing a factory method or builder pattern if some flexibility is needed while maintaining the performance benefits for the core attributes.