__slots__ Optimization — Deep Dive

How CPython Implements slots

When CPython encounters __slots__ during class creation, the type metaclass (type.__new__) does several things:

  1. Skips creating __dict__ descriptor — the tp_dictoffset is set to 0
  2. Creates member_descriptor objects for each slot name
  3. 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:

Operationdict (ns)slots (ns)Speedup
Read3530~15%
Write4236~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.

pythonperformanceoop

See Also