PyDepth

The Shape of a Python Object

An instance with three attributes costs 96 bytes, not the 344 that sys.getsizeof reports, and __slots__ makes attribute reads slower on CPython 3.13.

4 min read

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.
Share

Related posts

Arrow keys to move, Enter to open.