
The received wisdom about Python objects has two parts. The first is that an instance
carries a dictionary and that dictionary is expensive. The second is that __slots__ fixes
both the memory and the speed. The first part is half true in a way that matters; the second
part is half wrong in a way that matters more.
What the naive measurement says
Ask the interpreter how big an instance is and it will answer, at length, incorrectly:
class Plain:
def __init__(self, x, y, label):
self.x = x
self.y = y
self.label = label
>>> import sys
>>> obj = Plain(1, 2, "row")
>>> sys.getsizeof(obj), sys.getsizeof(obj.__dict__)
(48, 296)
>>> sys.getsizeof(obj) + sys.getsizeof(obj.__dict__)
344
Three integers’ worth of references, 344 bytes. The equivalent class with __slots__
reports 56. That is a ratio of six to one, and it is the number that gets quoted in code
review.
It is wrong because sys.getsizeof is a per-object question and an instance dictionary is
not a per-object thing. Since PEP 412 landed in Python 3.3, a dictionary created as an
instance’s __dict__ splits into a keys table, shared by every instance of that class, and
a values array, which is the only part each instance actually pays for. getsizeof is
handed one dict and reports what a standalone dict of that shape would cost. It has no way
to know the keys are borrowed.
What the real measurement says
The way to get the true figure is to allocate a great many instances and ask the allocator,
not the object. tracemalloc does exactly that. The harness allocates the list’s storage
first so the list’s own pointers are not counted, and uses small interned integers and one
shared string so nothing is paying for the payload:
rows = [None] * N # allocated before the measurement starts
tracemalloc.start()
before = tracemalloc.get_traced_memory()[0]
for i in range(N):
rows[i] = cls(i % 256, (i + 1) % 256, "row")
after = tracemalloc.get_traced_memory()[0]
Running it over the four ways of spelling the same three fields:
$ python3 experiments/objshape/sizes.py
python 3.13.2 on darwin
200,000 instances of three fields each
class getsizeof+dict traced bytes/inst
Plain 344 96.0
Slotted 56 56.0
Data 344 96.0
SlottedData 56 56.0
The real difference is 40 bytes an instance, not 288. getsizeof overstates the dict case
by 3.6x. It is also worth noticing that @dataclass and @dataclass(slots=True) land
exactly on their hand-written equivalents — the decorator generates the same code you would
have written, and changes nothing about storage.
Forty bytes is still forty bytes. At a million rows in memory it is 40 MB, which is a real
number in a worker that is already close to its limit. The point is not that __slots__ is
pointless; the point is that the saving is 1.7x rather than 6x, and a decision made on the
6x figure was made on a measurement that never existed.
The part that goes the other way
The second half of the folklore says __slots__ also makes attribute access faster, because
a slot is a fixed offset and a dict is a hash lookup. That was true. On a current
interpreter it no longer is:
$ python3 experiments/objshape/access.py
python 3.13.2 on darwin
best of 7, 2,000,000 reads each
instance dict 5.95 ns
__slots__ 6.66 ns
tuple index 6.27 ns
Reads through the instance dictionary are the fastest of the three, by about 12% over
__slots__. Each figure is the best of seven runs of two million reads, and the gap is
0.71 ns — small in absolute terms, and pointing the opposite way to the folklore.
The mechanism is the specialising interpreter. Since 3.11, LOAD_ATTR rewrites itself once
it has seen what kind of object it is looking at. For an instance with a shared keys table
it becomes LOAD_ATTR_INSTANCE_VALUE, which checks the type version and then indexes
straight into the values array — no hashing, no probing. For a slot it becomes
LOAD_ATTR_SLOT, which goes through the slot descriptor. Both are fast paths; the
inline-values one happens to be slightly shorter.
There is a further consequence that is easy to miss. __slots__ and the inline-values
optimisation are alternatives, not layers. A class with __slots__ does not get inline
values, because it has no dict to inline. So the two halves of the folklore are not merely
independent — choosing the memory win costs you the access path that would otherwise have
been marginally quicker.
Where this leaves the decision
Use __slots__ when you are holding many instances at once and the 40 bytes each multiply
into something you can see in RSS. That is a real case: a table loaded into memory, a graph
of nodes, a parser’s token stream.
Do not use it for speed. On 3.13 it is slightly slower for reads, and the difference is small enough that it should not drive the decision either way.
And do not use sys.getsizeof to decide anything about instances at all. It answers a
question about one object in isolation, and an instance is not in isolation — it shares a
keys table with its siblings and points at values it does not own. The number it returns is
not the number you are paying.
Where to stop
Every figure above is CPython 3.13.2 on one machine. The 40-byte gap is a layout fact and will look similar anywhere; the 0.7 ns access gap is an artefact of one interpreter’s specialisation table and should be re-measured on the version you actually ship.
Before restructuring a class, measure the process, not the object. If the instances in question number in the thousands rather than the millions, 40 bytes each is 40 KB, and the right answer is to leave the class alone.
Frequently asked
- Should I stop using __slots__?
- No — but use it for the memory, not for the speed. The 40 bytes per instance are real and they multiply. The read-speed advantage, on CPython 3.13, is not there.
- Why does sys.getsizeof report 296 bytes for the instance dict?
- Because it measures a standalone dict of that size. An instance dict created from a class whose layout is stable shares its keys table with every other instance of that class, so the per-instance cost is only the values array.
- Does dataclass change any of this?
- Not by itself. A plain @dataclass produces exactly the same layout as a hand-written __init__. @dataclass(slots=True) produces exactly the same layout as a hand-written __slots__. The decorator is a code generator, not a storage strategy.


