Inside the interpreter · Part 1 of 4

The Shape of a Python Object

An instance with three attributes costs 96 bytes, not the 344 sys.getsizeof reports, and __slots__ read 12% slower on the CPython 3.13.2 build measured here.

Elliot Sayer5 min readUpdated:

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:

  1. objshape/sizes.py — Instance and container sizes, and what getsizeof leaves out
  2. objshape/access.py — Attribute read timings across dict, slots and NamedTuple

Read, rather than assumed:

  1. PEP 412 — Key-Sharing Dictionary
  2. sys.getsizeof, Python 3.13 documentation
  3. Python/bytecodes.c at CPython v3.13.2
Share

Arrow keys to move, Enter to open.