Somebody reads a blog post from 2014, opens a PR titled “perf: add slots to core models”, and the diff touches ninety classes. The review is two approving emoji. Six weeks later a cached_property raises, a WeakSet stops holding anything, and the memory graph is unchanged.
This is the article that gets the numbers right, so that when you do reach for slots=True you can say what you expect to happen.
What __slots__ actually is
A normal Python object stores its attributes in a per-instance dictionary, reachable as obj.__dict__. Attribute lookup is a dict lookup, attribute creation is a dict insert, and you can invent new attributes at any time.
__slots__ replaces that with a fixed set of descriptors on the class, each pointing at a numbered slot in a flat C-level array on the instance. There is no __dict__. The set of attribute names is decided when the class is created and cannot grow.
class WithDict:
def __init__(self, a: int, b: int, c: int) -> None:
self.a, self.b, self.c = a, b, c
class WithSlots:
__slots__ = ("a", "b", "c")
def __init__(self, a: int, b: int, c: int) -> None:
self.a, self.b, self.c = a, b, c
@dataclass(slots=True) writes that tuple for you — and, importantly, returns a brand-new class object to do it, because __slots__ must exist before the class is created. Everything in part 2 follows from that sentence.
The memory number
Measured on CPython 3.14.6, macOS arm64, one million instances of a three-int class, with tracemalloc:
| shape | bytes per instance |
|---|---|
plain class with __dict__ |
106.5 |
__slots__ |
74.2 |
That is a ~30% saving, not the 5–10× the folklore promises. If your service holds two million records in memory, slots buys you about 65 MB. Real, worth having at that scale, and nowhere near a reason to touch ninety classes.
💡The classic "slots saves 10× memory" claim was true once. What changed in CPython to make it stop being true? click to reveal
Two things, both aimed at exactly this cost.
PEP 412 key-sharing dictionaries (3.3). Instances of the same class overwhelmingly have the same attribute names. Key-sharing splits a dict into a shared key table — stored once, on the class — and a per-instance array of values. So the “dictionary” on each instance is, in the common case, close to a flat array of pointers plus a small header. Most of what slots was saving, the dict now saves by itself.
Lazily created instance namespaces (3.11). An object’s __dict__ is not materialised until something actually needs it. Construct an object, set attributes through the fast path, never touch __dict__, and no dict object is ever allocated.
Together these turn the historic 5–10× into today’s ~30%. The blog posts were not wrong when they were written; they simply describe an interpreter that no longer exists.
The attribute-access number
| operation |
with __dict__ |
with __slots__ |
|---|---|---|
| attribute read | 3.9 ns | 3.8 ns |
There is no speed difference. Not “a small one” — none you can measure above noise. A slot read is a descriptor call into a fixed offset; a dict read on a key-shared dict is a lookup that the specialising interpreter has already turned into an inline-cached fast path. Both are a handful of nanoseconds.
If somebody justifies slots=True with “it’s faster”, ask them to show you the benchmark. There is a real construction-time win — @dataclass(slots=True) measures 35.1 ns versus 43.5 ns for a plain dataclass, roughly 20% — but that is construction, not access, and it is a small absolute number.
💡A colleague benchmarks with sys.getsizeof(obj) and reports "48 bytes with slots, 96 without — it's 2×!". What is wrong with the measurement?
click to reveal
sys.getsizeof reports the size of that object and does not follow references. For an object with a __dict__, the dict is a separate object: getsizeof returns 48 for the instance and says nothing about the ~296 bytes the dict occupies. So the naive comparison is not 48 vs 96 — it is 48 vs (48 + a dict you did not count).
Worse, it is not additive either, because key sharing means part of that dict is amortised across every instance of the class. There is no per-object number that is both simple and correct.
The right tool is tracemalloc: allocate a large number of instances inside a tracemalloc snapshot pair and divide total allocated bytes by the count. That measures what you actually care about — the marginal cost of one more object — and it is where the 106.5 / 74.2 numbers come from.
Where slots does win big
The 30% figure assumes the dict stays in its optimised state. Two things knock it out of that state, and then the comparison changes completely:
| situation | bytes per instance |
|---|---|
baseline __dict__ |
106.5 |
__slots__ |
74.2 |
after touching obj.__dict__ directly |
170.6 |
| after breaking key sharing | 373.8 |
Touching obj.__dict__ forces the namespace to be materialised. Breaking key sharing — giving different instances different attribute sets, or assigning attributes outside __init__ in varying orders — forces every instance to carry its own key table. At 373.8 vs 74.2 you are finally looking at the 5× the folklore promised.
So the honest rule is not “slots saves memory”. It is: slots gives you a fixed, predictable per-instance cost, and removes your ability to accidentally fall off the optimised path. If your objects are uniform and well-behaved, you save 30%. If they are not, you save a lot — and the reason you were not saving it already is a bug in the object’s design.
💡Which of these classes would benefit most from slots=True, and why? (a) a config object instantiated once at startup; (b) a point/vector type constructed in a numeric inner loop; (c) a plugin base class whose subclasses attach arbitrary attributes at registration time.
click to reveal
(b), and it is not close.
(a) One instance. Saving 32 bytes once is not a thing. Adding slots here costs you the ability to monkeypatch a setting in a test and buys nothing.
(b) Millions of short-lived, uniform instances with a fixed attribute set. This is the shape slots is for: the 30% is multiplied by a large number, the ~20% construction win applies on the hot path, and the fixed attribute set is genuinely part of the type’s contract.
(c) The worst candidate, and also the one where the naive memory argument looks best. Arbitrary per-subclass attributes are exactly what breaks key sharing — so the dict version really is 373.8 bytes and slots really would be 5× better. But you cannot use slots here: the whole design depends on attaching attributes the base class does not know about. slots=True would turn every plugin registration into an AttributeError.
The pattern worth taking away: the classes where slots saves the most are frequently the classes whose design forbids it.
The real reason to use it
Everything above is about bytes and nanoseconds, and on that evidence slots=True is a modest optimisation you apply selectively. But there is a second argument, and it is the stronger one.
Under mypy, slots=True turns a typo into an error.
@dataclass
class Config:
timeout: float
def bump(self) -> None:
self.timeuot = self.timeout * 2 # mypy: fine. runtime: fine. behaviour: wrong.
On a plain dataclass, mypy accepts any method defining any new attribute on self. It has to: that is legal Python, and there is no declaration to contradict. The attribute is created, nothing reads it, and the bug is a silent no-op that survives code review because the line looks right.
Add slots=True and mypy knows the complete set of attribute names. self.timeuot = ... becomes:
error: "Config" has no attribute "timeuot" [attr-defined]
That is a whole bug class deleted, on a codebase-wide basis, for one keyword argument. It costs nothing at runtime and it does not depend on anyone remembering to run a profiler.
💡slots=True makes the attribute set closed. Name a legitimate pattern it breaks, and say what you would do instead.
click to reveal
Several, and part 2 catalogues eight of them. The two that bite most often in a dataclass codebase:
functools.cached_property stores its computed value in self.__dict__. No __dict__, no cache — it raises. The stdlib has no fix; the options are a plain @property recomputing each time, a manual cache field written with object.__setattr__, or attrs, whose slotted classes support cached_property properly by generating a dedicated slot.
Weak references. By default a slotted class has no __weakref__ slot, so weakref.ref(obj), WeakSet and WeakValueDictionary all raise TypeError. Any observer registry, cache-with-eviction or parent back-pointer that avoids a reference cycle depends on this. The fix is one flag — @dataclass(slots=True, weakref_slot=True), available since 3.11 — but it is opt-in, and the failure surfaces far from the change.
The meta-answer: slots=True is a declaration about the type, not a compiler flag. Apply it where “this object has exactly these attributes, forever” is a true statement you want enforced. Do not apply it in bulk to a package you have not read.
What to do on Monday
-
Do not run a codemod that adds
slots=Trueeverywhere. Part 2 lists the eight ways that breaks, several of them at runtime, in production, months later. -
Default your value objects to
@dataclass(frozen=True, kw_only=True). Addslots=Truewhen the class is instantiated at scale, or when you want the typo protection on a class with real methods. -
Add
weakref_slot=Trueat the same time asslots=Trueunless you are certain nothing holds the object weakly. -
Measure with
tracemalloc, neversys.getsizeof, and measure a million instances rather than one. - When you write the PR description, say which benefit you are claiming — 30% memory, 20% construction, or static typo-catching. All three are real. Only one of them is usually the reason.