Introduction
Python's dynamic nature makes it flexible, but that flexibility comes with memory overhead. Every object stores its attributes in a dictionary (__dict__), which consumes significant memory especially when creating millions of simple data-holding objects. This article examines __slots__, a Python feature that eliminates this overhead, and explores when it improves code quality versus when it introduces unnecessary complexity.
The Problem: Dictionary Overhead
When you define a simple class in Python:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
p = Point(1, 2)
print(p.__dict__) # {'x': 1, 'y': 2}
Each Point instance carries a dictionary. On a 64-bit system, an empty dictionary consumes approximately 72 bytes. For a class with two integers, the dictionary overhead exceeds the data itself. When creating large datasets such as geometric coordinates, CSV records, or graph nodes this overhead accumulates rapidly.
The Solution: __slots__
__slots__ declares fixed attributes for a class, replacing the dynamic dictionary with a fixed-size array:
class Point:
__slots__ = ('x', 'y')
def __init__(self, x, y):
self.x = x
self.y = y
p = Point(1, 2)
# p.__dict__ # AttributeError: 'Point' object has no attribute '__dict__'
Memory Impact: Quantified
Testing with 1,000,000 instances on CPython 3.11 (64-bit):
| Implementation | Bytes per Instance | Total Memory |
|---|---|---|
Standard __dict__
|
~152 bytes | ~152 MB |
With __slots__
|
~56 bytes | ~56 MB |
| Reduction | 63% | 96 MB |
import sys
class DictPoint:
def __init__(self, x, y):
self.x = x
self.y = y
class SlotPoint:
__slots__ = ('x', 'y')
def __init__(self, x, y):
self.x = x
self.y = y
print(sys.getsizeof(DictPoint(0, 0))) # ~48 bytes (object header + dict pointer)
print(sys.getsizeof(SlotPoint(0, 0))) # ~40 bytes (object header only)
# Full overhead includes the separate dictionary object for DictPoint
How __slots__ Works
Under CPython's object model, instances store attributes in one of two ways:
Standard Objects:
- Instance holds a pointer to
__dict__(a hash table) - Attribute access requires hash computation and table lookup
- New attributes can be added dynamically at any time
Slotted Objects:
- Attributes stored in a fixed array at known offsets (like C structs)
- Access uses array indexing—faster and more cache-friendly
- Attribute set is fixed at class definition time
Practical Implementation Patterns
1. Basic Data Classes
class Particle:
__slots__ = ('position', 'velocity', 'mass')
def __init__(self, position, velocity, mass):
self.position = position
self.velocity = velocity
self.mass = mass
2. Inheritance Considerations
When using inheritance with __slots__, each class must declare its own slots. Empty __slots__ prevents the creation of __dict__ while inheriting parent's slots:
class Entity:
__slots__ = ('id', 'created_at')
class User(Entity):
__slots__ = ('email', 'name') # Has id, created_at, email, name
class Guest(Entity):
__slots__ = () # Has only id, created_at, no __dict__
3. Preserving Dynamic Attributes
If some instances need dynamic attributes, explicitly include __dict__ in slots:
class Flexible:
__slots__ = ('required_field', '__dict__')
def __init__(self):
self.required_field = "fixed"
# Can still add: self.dynamic = "value"
4. Enabling Weak References
Objects using __slots__ do not support weak references by default. Add __weakref__ to enable this:
class Node:
__slots__ = ('value', 'children', '__weakref__')
# Now weakref.ref(instance) works
When to Use __slots__
Appropriate scenarios:
- High-volume data objects (millions of instances)
- Simple data containers with fixed schemas
- Performance-critical attribute access
- Memory-constrained environments (embedded, mobile, containers)
Inappropriate scenarios:
- Classes with frequently changing attributes
- APIs requiring arbitrary attribute assignment by users
- Classes needing simple pickling without custom methods
- Code where readability matters more than marginal memory gains
Performance Beyond Memory
__slots__ improves runtime performance through faster attribute access:
import timeit
class DictObj:
def __init__(self):
self.x = 0
class SlotObj:
__slots__ = ('x',)
def __init__(self):
self.x = 0
# Attribute access timing
dict_time = timeit.timeit('obj.x', setup='from __main__ import DictObj; obj = DictObj()')
slot_time = timeit.timeit('obj.x', setup='from __main__ import SlotObj; obj = SlotObj()')
print(f"Dict access: {dict_time:.4f}")
print(f"Slot access: {slot_time:.4f}")
# Slot access typically 10-20% faster due to array indexing vs hash lookup
Common Pitfalls
Pitfall 1: Multiple inheritance conflicts
class A:
__slots__ = ('a',)
class B:
__slots__ = ('b',)
class C(A, B):
__slots__ = ('c',) # Valid
class D(A, B):
pass # Also valid—creates __dict__ if not using slots
Pitfall 2: Forgetting __weakref__
class Cached:
__slots__ = ('data',)
import weakref
c = Cached()
# weakref.ref(c) # TypeError: cannot create weak reference
Pitfall 3: Pickling complications
Standard pickling works with __slots__, but custom __getstate__ and __setstate__ may be needed for complex scenarios:
class Persistent:
__slots__ = ('value', 'temp_cache')
def __getstate__(self):
return {'value': self.value} # Exclude temp_cache
def __setstate__(self, state):
self.value = state['value']
Design Best Practices
1. Start Simple, Optimize When Measured
Do not default to __slots__. Write standard classes first. Use memory profiling (tracemalloc, pympler) to identify actual bottlenecks before adding complexity.
2. Document Intent Clearly
class Coordinate:
"""
Memory-optimized coordinate for large-scale geometry processing.
Uses __slots__ to reduce footprint from ~152 bytes to ~56 bytes
per instance, critical when handling millions of points.
"""
__slots__ = ('x', 'y', 'z')
3. Abstract Implementation Details
If switching between slotted and dict-based implementations, use a factory or abstract the decision:
from dataclasses import dataclass
from typing import NamedTuple
# Option 1: dataclass with slots (Python 3.10+)
@dataclass(slots=True)
class Point:
x: float
y: float
# Option 2: NamedTuple (immutable, automatically slotted)
class Point(NamedTuple):
x: float
y: float
4. Test Both Implementations
When performance matters, maintain test suites verifying both implementations produce identical behavior:
def test_coordinate_behavior(PointClass):
p = PointClass(1.0, 2.0)
assert p.x == 1.0
assert p.y == 2.0
# Test all expected behavior...
# Run against both implementations
test_coordinate_behavior(DictPoint)
test_coordinate_behavior(SlotPoint)
Alternatives to Consider
Before adopting __slots__, evaluate these alternatives:
| Approach | Memory Efficiency | Mutability | Use Case |
|---|---|---|---|
__slots__ |
High | Yes | Many mutable objects |
NamedTuple |
High | No | Immutable records |
@dataclass(slots=True) |
High | Yes | Clean syntax, Python 3.10+ |
array.array |
Very High | Yes | Homogeneous numeric data |
numpy.ndarray |
Very High | Yes | Numerical computing |
pandas.DataFrame |
Very High | Yes | Tabular data |
Conclusion
__slots__ is a specialized tool, not a universal improvement. It trades Python's dynamic flexibility for memory efficiency and attribute access speed. Use it when profiling identifies memory pressure from object overhead, particularly in data-intensive applications. Avoid it when code clarity, flexibility, or rapid prototyping takes priority.
The most robust approach: write clear, correct code first, measure resource usage, then apply targeted optimizations where data proves they matter.
Top comments (0)