The slots Optimization
__slots__is a class-level declaration that tells CPython to store an instance’s attributes in a fixed C array of pointers at known offsets inside the instance, rather than in a per-instance dictionary. Quoting the language reference, “__slots__allow us to explicitly declare data members (like properties) and deny the creation of__dict__and__weakref__” — and “the space saved over using__dict__can be significant. Attribute lookup speed can be significantly improved as well” (Python 3.14 datamodel —__slots__). Mechanically, each name in__slots__becomes a member descriptor on the class: a descriptor whose__get__/__set__read and write one slot at a fixed byte offset in the instance. By eliminating the per-instance dictionary you trade away dynamic attribute addition for a smaller, faster object.
The one idea to take away: a normal Python instance is a thin header plus a pointer to a separate dict that holds its attributes; a slotted instance inlines its attributes as raw PyObject* pointers and has no dict at all. The savings are the eliminated dictionary; the cost is that you can only ever have the attributes you declared.
This note is about the optimization itself. The descriptor protocol the slots are built on is Descriptors; the attribute-lookup machinery that finds a slot descriptor is Attribute Lookup Mechanics; the dictionary you are avoiding is CPython Dict Internals.
Mental Model
Picture two instances of a three-attribute class. The ordinary one is a small object header followed by a single pointer to a dict living elsewhere on the heap; the dict is where x, y, z actually live, alongside the dict’s own hash table machinery. The slotted one has no dict pointer — instead, three PyObject* slots sit inline in the instance body, at fixed offsets, and the class carries three member descriptors (one per name) that know those offsets.
flowchart LR subgraph Dict-based instance H1["PyObject header<br/>(refcnt, type)"] --> D["__dict__ ptr"] D --> DD["separate dict object<br/>{'x':.., 'y':.., 'z':..}<br/>+ hash table overhead"] end subgraph Slotted instance H2["PyObject header<br/>(refcnt, type)"] --> S1["slot 0: x (PyObject*)"] S1 --> S2["slot 1: y (PyObject*)"] S2 --> S3["slot 2: z (PyObject*)"] end C["class object"] -.->|"x,y,z are<br/>member_descriptor<br/>at offsets"| S1
What this shows: the dict-based instance pays for an entire second heap object (the dict) and an extra pointer hop to reach any attribute; the slotted instance stores attributes inline and reaches them by adding a compile-time-known offset to the instance pointer. The insight: __slots__ does not make the instance header smaller — it makes the total footprint smaller by deleting the dict, and it makes access faster by replacing a hash lookup with a fixed offset.
The Memory Savings — Quantified
This is where naive intuition misleads, so it is worth measuring. On CPython 3.14.5:
import sys
class WithDict:
def __init__(self, x, y, z): self.x, self.y, self.z = x, y, z
class WithSlots:
__slots__ = ('x', 'y', 'z')
def __init__(self, x, y, z): self.x, self.y, self.z = x, y, z
a, b = WithDict(1, 2, 3), WithSlots(1, 2, 3)
sys.getsizeof(a) # 48 -- instance header only
sys.getsizeof(a.__dict__) # 296 -- the SEPARATE dict holding x,y,z
sys.getsizeof(b) # 56 -- slotted instance, x/y/z inlineRead these numbers carefully, because at first glance slots looks worse: the slotted instance is 56 bytes and the dict-based instance is only 48. The catch is that the 48-byte dict-based instance is not the whole object — it carries a pointer to a separate 296-byte dictionary. The real comparison is:
- Dict-based total:
48 (instance) + 296 (dict) = 344 bytes. - Slotted total:
56 bytes(the three attribute pointers are inlined into the body; there is no second object).
So the genuine saving is roughly 344 → 56 bytes per instance, about a 6× reduction, and it grows in absolute terms with object count: ten million dict-based instances cost on the order of gigabytes more than ten million slotted ones. The slotted instance is a hair larger than the dict-based header (56 vs 48) precisely because it now stores the three attribute pointers that used to live in the external dict; the win is deleting the dict, its hash table, and its growth slack. (Modern CPython further optimizes the dict case with a “managed dict” / key-sharing layout that lazily materializes __dict__; the details belong to CPython Dict Internals — but even the lazy dict is a separate allocation the moment you set an attribute.)
Mechanical Walk-through — Slots Are Member Descriptors
When the metaclass builds a class whose namespace contains a __slots__ entry, type.__new__ does three things (CPython Objects/typeobject.c @ 3.14):
-
It grows the instance layout. For each slot name it reserves one pointer-sized cell in the instance and bumps the type’s
tp_basicsizeaccordingly. The slot’s byte offset is recorded. This is why the instance body physically contains the attribute pointers — they are allocated as part of the instance struct, not in a side dict. -
It creates one member descriptor per slot. Each name becomes a
PyMemberDefof typePy_T_OBJECT_EX, andtype.__new__wraps it in amember_descriptorobject (created viaPyDescr_NewMember) that it stores in the class__dict__under the slot’s name. You can see this directly:type(WithSlots.x)is<class 'member_descriptor'>. Amember_descriptoris a genuine data descriptor — it defines both__get__and__set__— so it participates in normal attribute lookup and overrides any instance dict, which is consistent with there being no instance dict to override. -
It suppresses
__dict__and__weakref__. Unless you explicitly list'__dict__'or'__weakref__'in__slots__, the type is built without the header fields that would point at a per-instance dict or weak-reference list.
At runtime, instance.x resolves to the class’s member_descriptor, whose __get__ computes *(PyObject **)((char *)instance + offset) — read the pointer at the slot’s fixed offset. Assignment instance.x = v calls __set__, which Py_XSETREFs the same slot. Reading an unset slot is where Py_T_OBJECT_EX earns its name: an unassigned Py_T_OBJECT_EX slot holds NULL, and the descriptor raises AttributeError rather than returning a garbage or None value (typeobject.c). That is why a slot you have not yet assigned behaves like a missing attribute, not like x = None.
The speed win follows directly: a dict-based access is a hash of the name string plus a probe into the instance dict’s table; a slot access is “add a constant offset, dereference.” Under the specializing adaptive interpreter, LOAD_ATTR on a slot specializes to an even tighter slot-load opcode. The reference docs’ claim that “attribute lookup speed can be significantly improved” is this offset-vs-hash difference.
The Rules and Gotchas
The language reference’s “Notes on using __slots__” enumerates the sharp edges; every one is a direct consequence of the mechanism above (Python 3.14 datamodel — Notes on using __slots__).
No new attributes. “Without a __dict__ variable, instances cannot be assigned new variables not listed in the __slots__ definition. Attempts to assign to an unlisted variable name raises AttributeError.” There is literally no storage for an undeclared name. To opt back into dynamic attributes, add '__dict__' to __slots__ — which of course gives back the dict and most of its cost.
No weak references by default. “Without a __weakref__ variable for each instance, classes defining __slots__ do not support weak references to its instances.” If weakref.ref(obj) must work, add '__weakref__' to __slots__. This bites in practice with caches, observer patterns, and anything using weakref.WeakValueDictionary — see CPython Weak References.
Class attributes collide with slot descriptors. “__slots__ are implemented at the class level by creating descriptors for each variable name. As a result, class attributes cannot be used to set default values for instance variables defined by __slots__; otherwise, the class attribute would overwrite the descriptor assignment.” You cannot write __slots__ = ('x',) and then x = 0 in the class body to give x a default — the x = 0 clobbers the descriptor and you get a read-only class attribute plus a broken slot. Defaults must be set in __init__.
The inheritance rule — the big one. “__slots__ declared in parents are available in child classes. However, instances of a child subclass will get a __dict__ and __weakref__ unless the subclass also defines __slots__.” And conversely, “when inheriting from a class without __slots__, the __dict__ and __weakref__ attribute of the instances will always be accessible.” This is the most commonly missed point: a single __slots__-less class anywhere in the MRO reintroduces the dict for the whole instance. If you slot a base class but forget to slot a subclass, every subclass instance silently regains a full __dict__ and the memory optimization evaporates. To keep the optimization, every class in the hierarchy must declare __slots__ (an empty __slots__ = () is the idiom for a subclass that adds no new attributes).
Multiple-inheritance slot conflict. “Multiple inheritance with multiple slotted parent classes can be used, but only one parent is allowed to have attributes created by slots (the other bases must have empty slot layouts) — violations raise TypeError.” Because each slotted base imposes its own instance layout and they cannot be merged, you may combine several slotted bases only if at most one has nonempty slots; the rest must be __slots__ = (). This is a layout constraint, not an arbitrary rule — two nonempty slot layouts would need conflicting offsets.
Variable-length builtins reject nonempty slots. “TypeError will be raised if nonempty __slots__ are defined for a class derived from a ‘variable-length’ built-in type such as int, bytes, and tuple.” Those types store a variable number of items inline after the header (see CPython Tuple Internals, CPython Integer Internals), so there is no fixed place to put extra slots.
Re-declaring a base-class slot is undefined. “If a class defines a slot also defined in a base class, the instance variable defined by the base class slot is inaccessible … This renders the meaning of the program undefined. In the future, a check may be added to prevent this.” Don’t repeat a base’s slot name in a subclass.
__class__ reassignment requires identical layouts. “__class__ assignment works only if both classes have the same __slots__.” You cannot swap an object’s class to one with a different slot layout, because the memory shape would no longer match.
Slots can be any iterable, including a mapping for docs. “Any non-string iterable may be assigned to __slots__.” And usefully: “If a dictionary is used to assign __slots__, the dictionary keys will be used as the slot names. The values of the dictionary can be used to provide per-attribute docstrings that will be recognised by inspect.getdoc() and displayed in the output of help().” One subtle trap: “If an iterator is used for __slots__ then a descriptor is created for each of the iterator’s values. However, the __slots__ attribute will be an empty iterator” — pass a list/tuple/dict, not a one-shot generator, if you want __slots__ to remain inspectable.
Interaction with @dataclass(slots=True)
Hand-writing __slots__ is verbose and easy to get wrong (especially the inheritance rule), so since Python 3.10 the @dataclass decorator accepts a slots parameter (dataclasses docs — slots, “Added in version 3.10”). “If true (the default is False), __slots__ attribute will be generated and new class will be returned instead of the original one. If __slots__ is already defined in the class, then TypeError is raised.” Two things in that sentence matter:
- It generates
__slots__from the declared fields automatically, so you get the memory/speed benefits without writing the tuple by hand. - It returns a new class, not the original — the decorator cannot add slots to an already-created class in place (the layout is fixed at class creation), so it rebuilds the class. Code that captured a reference to the pre-decoration class, or that relies on the class object’s identity, can be surprised.
Caveats the docs call out: passing parameters to a base class’s __init_subclass__ with slots=True raises TypeError (use no-parameter __init_subclass__ or defaults — see gh-91126); and since Python 3.11, a field already present in a base class’s __slots__ is not re-emitted into the generated __slots__, to avoid the “redefining a base slot is undefined” trap — so you must not use __slots__ to enumerate a dataclass’s fields; use dataclasses.fields() instead. Weak-reference support gets its own knob: weakref_slot=True (added in 3.11) adds a '__weakref__' slot, and it is an error to pass it without slots=True.
The interaction with field defaults is the classic dataclass-slots gotcha and a direct instance of the “class attributes collide with slot descriptors” rule above: a dataclass field default is stored as a class attribute, which would clobber the slot descriptor. Modern @dataclass(slots=True) handles simple defaults by moving them into the generated __init__, but mutable defaults via field(default_factory=...) and some descriptor-typed fields have historically needed care.
Uncertain
Verify: the exact
@dataclass(slots=True)× field-default /default_factoryedge-case behavior on 3.14. As of 2026-06-01, the 3.14 dataclasses docs document only theslotsversion history (added 3.10; the 3.11 change that base-class slots are not re-emitted;weakref_slotadded 3.11) and do not document any defaults-with-slots interaction or a “Changed in version 3.12/3.13/3.14” note for it. The high-level rule stated above (defaults are moved off the class so they don’t clobber slot descriptors) is stable, but the precise patch-release edge-case behavior (e.g. defaults being lost,KW_ONLYinteractions) is not settled by the rendered docs. To resolve: comb thegh-issue tracker fordataclasses+slots+defaultregressions fixed in the 3.12–3.14 line and readLib/dataclasses.pyat the v3.14.5 tag. uncertain
Common Misunderstandings
- “
__slots__makes the instance smaller.” Partly: it makes the total footprint smaller by removing the dict; the instance header may be the same size or slightly larger because it now holds the attribute pointers inline. - “Slotting the base class is enough.” No — the inheritance rule means any
__slots__-less class in the MRO reintroduces__dict__. Slot the whole hierarchy. - “
__slots__is a speed feature.” It is mainly a memory feature; the access speedup is real but secondary, and is largely about avoiding the dict hash/probe. - “Slots are read-only / immutable.” Not at all — slot attributes are normal mutable attributes; you simply cannot add new (undeclared) ones. Unset slots raise
AttributeErroruntil first assigned, but that is thePy_T_OBJECT_EX“missing attribute” behavior, not immutability. - “Use
__slots__everywhere.” It costs flexibility (no dynamic attributes, no monkey-patching of instances, weakref/pickle wrinkles) and complicates inheritance. It pays off when you have many small instances of a known-shape class; for a handful of objects it is premature optimization.
Alternatives and When to Choose Them
- Plain instances (
__dict__) — the default. Choose when instances are few, the attribute set is dynamic, or flexibility (monkey-patching, ad-hoc attributes) matters more than per-object bytes. CPython’s key-sharing dict already softens the cost for many same-shape instances. @dataclass(slots=True)/attrs(slots=True)— the ergonomic way to get slots: declare fields once, get slots,__init__,__repr__, etc. generated. Choose this over hand-rolled__slots__for almost all new code that wants slots.NamedTuple/collections.namedtuple— immutable, tuple-backed, even more compact, but fixed and immutable. Choose when records are read-mostly and immutability is acceptable.array/ NumPy / struct-of-arrays — for huge homogeneous numeric data, leave the object model entirely;__slots__still pays onePyObject*per attribute, whereas a typed array pays one machine word per value.
Production Notes
The canonical use is large populations of small, fixed-shape objects: nodes in a graph or tree, rows materialized from a database, points/vectors in a simulation, AST nodes, event records in a hot loop. The reported wins are substantial — eliminating the per-instance dict commonly cuts per-object memory several-fold (consistent with the 344→56 byte measurement above) and reduces GC pressure (fewer dict objects to track). The most common production bug is the inheritance trap: a slotted base, an un-slotted subclass added later, and the memory optimization silently disappears with no error — which is one reason @dataclass(slots=True) (which slots the generated class) is the safer modern default. The second-most-common surprise is breaking weakref-based caches or pickling helpers that assumed __dict__ or __weakref__ exists.
See Also
- Descriptors — slots ARE descriptors; each slot is a
member_descriptordata descriptor - Attribute Lookup Mechanics — how
obj.attrfinds the slot descriptor and why a data descriptor wins over the (absent) instance dict - CPython Dict Internals — the per-instance
__dict__(and the key-sharing/managed-dict layout) that__slots__avoids - Type Objects and PyTypeObject —
tp_basicsize,tp_dictoffset,tp_weaklistoffsetare the fields slots manipulate - Object Creation new init and alloc — where the slotted instance’s inline storage is allocated
- CPython Weak References — why
__weakref__must be re-added for weakref support - Method Resolution Order and C3 Linearization — the MRO that the “any un-slotted base reintroduces
__dict__” rule walks - Python Internals MOC — §4 The Object Model