__slots__ Optimization — Deep Dive
How CPython Implements slots
When CPython encounters __slots__ during class creation, the type metaclass (type.__new__) does several things:
- Skips creating
__dict__descriptor — thetp_dictoffsetis set to 0 - Creates
member_descriptorobjects for each slot name - Allocates fixed offsets in the instance memory layout
Each slot becomes a descriptor on the class — similar to property, but implemented in C:
class Vector:
__slots__ = ('x', 'y', 'z')
print(type(Vector.x)) # <class 'member_descriptor'>
print(Vector.x) # <member 'x' of 'Vector' objects>
These descriptors use PyMemberDef with direct offset access into the object’s memory. When you access v.x, Python doesn’t hash “x” and probe a dictionary — it reads from a known memory offset.
Memory Layout Comparison
A regular object’s memory layout:
[PyObject_HEAD] → 16 bytes (refcount + type pointer)
[__dict__] → 8 bytes (pointer to dict object)
[__weakref__] → 8 bytes (pointer to weakref list)
= 32 bytes base
Dict object itself: ~104 bytes (empty) to ~232 bytes (few items)
Total: ~136-264 bytes per instance
A slotted object with 3 attributes:
[PyObject_HEAD] → 16 bytes
[slot x] → 8 bytes (pointer to object)
[slot y] → 8 bytes
[slot z] → 8 bytes
= 40 bytes total
Let’s verify with sys.getsizeof:
import sys
class Regular:
def __init__(self):
self.x = 1
self.y = 2
self.z = 3
class Slotted:
__slots__ = ('x', 'y', 'z')
def __init__(self):
self.x = 1
self.y = 2
self.z = 3
r = Regular()
s = Slotted()
print(sys.getsizeof(r)) # 48
print(sys.getsizeof(r) + sys.getsizeof(r.__dict__)) # 48 + 104 = 152
print(sys.getsizeof(s)) # 56
Note: sys.getsizeof on a regular instance shows 48 bytes but doesn’t count the __dict__. The total including the dict is 152+. The slotted version is 56 bytes — everything included.
Inheritance: The Full Picture
Single inheritance chain
Slots are cumulative. Each class in the chain declares only its own slots:
class Base:
__slots__ = ('a',)
class Middle(Base):
__slots__ = ('b',)
class Leaf(Middle):
__slots__ = ('c',)
obj = Leaf()
obj.a = obj.b = obj.c = 1 # All work
Never re-declare a parent’s slot in a child — it wastes memory and causes subtle bugs:
class Bad(Base):
__slots__ = ('a', 'b') # 'a' is already in Base!
# Creates a shadow slot — the Base.a descriptor and Bad.a
# descriptor compete, leading to confusing behavior
Multiple inheritance
Multiple slotted classes can only be combined if at most one has non-empty slots. This is a CPython limitation:
class X:
__slots__ = ('a',)
class Y:
__slots__ = ('b',)
class Z(X, Y): # TypeError: multiple bases have instance lay-out conflict
__slots__ = ()
The workaround is to use empty slots in mixins:
class XMixin:
__slots__ = ()
def x_method(self): ...
class Y:
__slots__ = ('a', 'b')
class Z(XMixin, Y):
__slots__ = ('c',) # Works — only Y has non-empty slots
weakref and slots
Regular objects support weak references via __weakref__. Slotted objects don’t, unless you explicitly include it:
import weakref
class NoWeakRef:
__slots__ = ('x',)
class WithWeakRef:
__slots__ = ('x', '__weakref__')
obj = WithWeakRef()
ref = weakref.ref(obj) # Works
obj2 = NoWeakRef()
ref2 = weakref.ref(obj2) # TypeError: cannot create weak reference
This costs an extra 8 bytes per instance. Include it when your objects might be cached, observed, or used in frameworks that rely on weak references.
Benchmarking Attribute Access Speed
import timeit
class DictObj:
def __init__(self):
self.x = 1
class SlottedObj:
__slots__ = ('x',)
def __init__(self):
self.x = 1
d = DictObj()
s = SlottedObj()
# Read access
dict_read = timeit.timeit('d.x', globals={'d': d}, number=10_000_000)
slot_read = timeit.timeit('s.x', globals={'s': s}, number=10_000_000)
# Write access
dict_write = timeit.timeit('d.x = 2', globals={'d': d}, number=10_000_000)
slot_write = timeit.timeit('s.x = 2', globals={'s': s}, number=10_000_000)
Typical results on CPython 3.12:
| Operation | dict (ns) | slots (ns) | Speedup |
|---|---|---|---|
| Read | 35 | 30 | ~15% |
| Write | 42 | 36 | ~14% |
The speedup is modest. It matters in inner loops of numerical code or parsers, not in typical application logic.
Slots with Dataclasses
Python 3.10+ supports slots in dataclasses natively:
from dataclasses import dataclass
@dataclass(slots=True)
class Coordinate:
x: float
y: float
z: float = 0.0
This is the modern way to combine slots with dataclass convenience. The generated class automatically defines __slots__ and handles defaults properly.
Pre-3.10 workaround:
@dataclass
class _CoordinateBase:
x: float
y: float
class Coordinate(_CoordinateBase):
__slots__ = ('x', 'y')
Production Pattern: Slots with init_subclass
Enforce slots in a class hierarchy:
class StrictBase:
__slots__ = ()
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
if '__slots__' not in cls.__dict__:
raise TypeError(
f"{cls.__name__} must define __slots__. "
f"Add __slots__ = () if no new attributes."
)
This prevents accidental __dict__ reintroduction in subclasses — useful in performance-critical codebases.
Serialization Gotchas
Slotted objects don’t pickle smoothly by default because pickle looks for __dict__:
import pickle
class SlottedData:
__slots__ = ('value',)
def __init__(self, value):
self.value = value
def __getstate__(self):
return {s: getattr(self, s) for s in self.__slots__}
def __setstate__(self, state):
for key, value in state.items():
setattr(self, key, value)
Without __getstate__/__setstate__, you’ll get TypeError or empty objects after unpickling.
When Slots Hurt
- Dynamic attribute requirements — monitoring, debugging, and test frameworks often attach metadata to objects
- Metaclass conflicts — some ORMs and frameworks manipulate
__dict__during class creation - C extensions — some extension types don’t play well with slots in multiple inheritance
The Django ORM, for example, does not use slots on model instances because it needs dynamic attribute assignment for deferred fields and annotations.
Profiling Before Optimizing
Always measure before adding slots:
import tracemalloc
tracemalloc.start()
objects = [Regular() for _ in range(100_000)]
snapshot = tracemalloc.take_snapshot()
print(snapshot.statistics('lineno')[0]) # Shows memory per allocation site
If object dictionaries aren’t your top memory consumer, slots won’t help. Profile with tracemalloc, objgraph, or memory_profiler first.
One thing to remember: __slots__ replaces Python’s per-instance hash table with fixed-offset memory slots — a CPython optimization that trades dynamic attribute flexibility for 40-60% memory savings and ~15% faster attribute access. Always profile first, and use @dataclass(slots=True) on Python 3.10+ for the cleanest approach.
See Also
- Python Algorithmic Complexity Understand Algorithmic Complexity through a practical analogy so your Python decisions become faster and clearer.
- Python Async Performance Tuning Making your async Python faster is like organizing a busy restaurant kitchen — it's all about flow.
- Python Benchmark Methodology Why timing Python code once means nothing, and how fair testing works like a science experiment.
- Python C Extension Performance How Python borrows C's speed for the hard parts — like hiring a specialist for the toughest job on the worksite.
- Python Caching Strategies Understand Python caching strategies with a shortcut-road analogy so your app gets faster without taking wrong turns.