__slots__ and its dataclass spelling slots=True are covered elsewhere in this course as a modelling tool. This article is about one narrower thing: how to find out what it is actually worth on the interpreter you are running, because both of the numbers people quote are wrong.
The two claims worth checking
“__slots__ cuts memory by 5-10x.” It does not. On modern CPython the saving is around 30% for a small object. The 5-10x figure comes from a era when instance dicts were unshared and much larger; key-sharing dictionaries (PEP 412, Python 3.3) already gave most of that back for free, by letting every instance of a class share one table of key strings and store only the values array.
30% is still worth having when you allocate millions of objects. It is not worth restructuring your class hierarchy for when you allocate thousands.
“__slots__ makes attribute access faster.” It does not, measurably: roughly 3.9 ns slotted versus 3.8 ns with a dict — noise, and pointing the wrong way. The reason is PEP 659, the specialising adaptive interpreter (3.11). A LOAD_ATTR in a hot loop specialises on the type it sees and caches the lookup, so a dict-backed attribute access on a stable type is already a couple of pointer dereferences. There is nothing left for slots to remove.
Both corrections point the same direction: measure on the interpreter you deploy, not on the interpreter the blog post was written against.
Why sys.getsizeof is the wrong instrument
The obvious experiment is wrong:
sys.getsizeof(regular_instance) # does not include its __dict__
sys.getsizeof(slotted_instance) # does not include the values it points at
getsizeof reports “the memory consumption directly attributed to the object… not the memory consumption of objects it refers to”. For a regular instance the __dict__ is a separate object, so the number you get systematically under-reports the thing you are trying to compare. You can chase it — getsizeof(obj) + getsizeof(obj.__dict__) — but now you are hand-rolling a traversal, you will forget the key-sharing table, and you still have not accounted for allocator overhead.
Article 11.12 is entirely about why that recursion is subtler than it looks.
What to use instead
tracemalloc, because it measures what the allocator actually did rather than what an object claims about itself:
import tracemalloc
tracemalloc.start()
before = tracemalloc.get_traced_memory()[0]
objects = [Point(i, i) for i in range(100_000)]
after = tracemalloc.get_traced_memory()[0]
tracemalloc.stop()
print((after - before) / 100_000, "bytes per instance")
Three properties make this the right tool. It counts every allocation the construction caused, including the dict, including the values array, including whatever the allocator rounded up to. It is per-line attributable, so you can see where the memory went rather than only how much. And it needs no privileges and no extra install — which matters when the only place the difference shows up is a locked-down production container.
The methodology that goes with it:
- Allocate many. One instance tells you nothing; per-object overhead is small and allocator noise is not. 100,000 is a reasonable floor.
- Hold a reference to all of them. If you allocate in a loop and drop each one, you measure peak-of-one, not total.
-
Measure the list separately. A
listof 100,000 pointers is 800 KB on its own, before any instance exists. Subtract it or you will attribute it to the objects. -
Compare like with like. Same field count, same field types, same construction path. A dataclass with defaults and a hand-written
__init__do not allocate the same way. - Report per-instance, not total. Totals do not transfer to anyone else’s workload.
💡You measure tracemalloc per-instance overhead for a slotted and a non-slotted version of the same class, find a 30% difference, and your service's RSS does not move at all when you deploy the change. Give two reasons that is entirely consistent.
click to reveal
First, you may not be allocating enough of them for it to matter. 30% of a small object across ten thousand instances is a few hundred kilobytes. If those objects were never the reason for your RSS, removing 30% of them changes nothing. The measurement was correct and the hypothesis — that instances dominate your heap — was never tested.
Second, tracemalloc measures allocations Python asked for; RSS measures pages the OS gave the process and has not taken back. CPython’s allocator does not generally return freed arenas to the kernel promptly, and fragmentation means freeing 30% of a size class may free zero pages. A workload can allocate strictly less and hold exactly as many pages.
The general form of this is worth internalising: tracemalloc answers “how much did my Python code allocate, and where”, RSS answers “how much memory does the OS think this process has”. They are different questions, and confusing them is how people end up convinced an optimisation “did nothing” when it did exactly what it said.
The costs that are real
Because the benefits are smaller than folklore claims, the costs matter more:
-
No
__dict__, so no ad-hoc attributes — which is often the point, since a typo’d assignment becomes anAttributeErrorinstead of a silently created field. -
No
__weakref__unless you ask for it.@dataclass(slots=True)cannot be weak-referenced; you needweakref_slot=True(article 11.6). -
@dataclass(slots=True)returns a new class object. The decorator cannot add slots to an existing class, so it builds a replacement. That breaks zero-argumentsuper()in methods (issue 90562) and interacts badly with__init_subclass__(issue 91126), because the class the method body closed over is not the class that ends up bound to the name. -
Multiple inheritance from two slotted classes with non-empty slots is a
TypeErrorat class creation.
The rule
Use slots=True on value types you allocate in bulk — rows, points, tokens, events — where the class is small, final in practice, and does not need weak references or dynamic attributes. Skip it everywhere else, and if someone claims it will fix a memory problem, ask for the tracemalloc number and the instance count before agreeing.