
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 attributes’ 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.
Corrected 2026-09-06. The 344 above is also not stable. The values-array term inside
dict.__sizeof__ is sized at dk_nentries + dk_usable, and dk_usable falls as instances
are created, so the same call on the same class reports 296 bytes for the first instance’s
dict and 96 once twenty-six of them exist. The 3.6x overstatement quoted here is therefore
the ratio at the least representative moment in a class’s life; in a running program it is
1.5x. The entry getsizeof Reports 296 Bytes, Then 96, for the Same Class takes that number
apart, and finds that getsizeof on the instance itself misses the values array entirely.
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 checks the type version and then reads a pointer at an offset
baked into the instruction.
Corrected 2026-08-30. This paragraph originally said that LOAD_ATTR_SLOT goes through
the slot descriptor at run time, and that the inline-values variant is the shorter of the
two. Both are wrong. The descriptor is consulted once, by the specialising code, to find the
offset; and read from Python/bytecodes.c, the slot variant runs one guard where the
instance-value variant runs two. The 0.71 ns above is real on this interpreter and does not
reproduce on other builds of the same CPython version — the entry that takes both
instructions apart measures five interpreters and finds the
gap on one of them.
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 — a class gets one path or the other, and which of the two is quicker turns out
to be a question about the interpreter build rather than about the class.
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 the build measured here it is slightly slower for reads. On other builds of the same version it is not, 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. The class body itself is charged once, at import, and what its annotations cost there is the larger number on most start-ups.
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 a split dict is charged the dict header plus a values array sized at dk_nentries + dk_usable, and dk_usable is at its highest for the first instance a class ever builds. Corrected 2026-09-06: the same call reports 96 bytes once twenty-six instances exist. See getsizeof Reports 296 Bytes, Then 96, for the Same Class.
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.
Where this came from
Run it yourself — every figure above came out of these:
- objshape/sizes.py — Instance and container sizes, and what getsizeof leaves out
- objshape/access.py — Attribute read timings across dict, slots and NamedTuple
Read, rather than assumed:


