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

- Published: 2026-07-14
- Tags: cpython, memory, performance
- Source: https://pydepth.com/blog/the-shape-of-a-python-object/
- Language: en-US
- Author: Elliot Sayer

---
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:

```python
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:

```python
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.
