Say you’re simulating a forest: a million trees, each with a position and an age.

class Tree:
    def __init__(self, x, y, age):
        self.x = x
        self.y = y
        self.age = age

Three small ints per tree. In C that’s 12–24 bytes a tree, so a million trees would take about 24 MB. In Python 3.10 the same million trees take 152 MB. In 3.13 they take 96 MB, without changing a line.

There are two ways to make a million objects cheap: make each object smaller, or make fewer objects. This post is about the first one. It covers where those bytes go, what __slots__ removes, and how much of that CPython 3.11+ now does for you.

How we measure

sys.getsizeof won’t do. It reports the size of the object struct only. On 3.10 that leaves out the instance’s __dict__, and on 3.13 it doesn’t even count the attribute values. It says a 3.13 Tree is 48 bytes when it really costs 96.

Instead, create a million instances and ask tracemalloc how much memory appeared:

objs = [None] * N                    # allocate the list first so it isn't counted
tracemalloc.start()
before = tracemalloc.get_traced_memory()[0]
for i in range(N):
    objs[i] = Tree(i % 100, i % 200, i % 50)
per_obj = (tracemalloc.get_traced_memory()[0] - before) / N

The attribute values are small ints. CPython keeps one shared copy of each int from -5 to 256, so creating trees allocates no ints and we measure only the Tree itself.

All numbers below come from measure.py, which is worth opening in another tab to follow along. They were measured with 64-bit CPython on macOS arm64, using uv-managed builds of 3.10 to 3.14. The memory numbers come out identical on every run. The timings are rough (±10%). 3.8 behaves exactly like 3.10, so it’s left out of the tables.

uv run --python 3.13 measure.py

A plain class

The dict you’d expect

Ask most Python programmers where tree.x lives and they’ll say “in tree.__dict__”. Every object carries a dict mapping attribute names to values. Taken literally, that means every tree has a complete dict of its own: a hash table holding the keys 'x', 'y' and 'age', their hashes, and the values.

You can build exactly that by assigning __dict__ yourself:

class TreeOwnDict:
    def __init__(self, x, y, age):
        self.__dict__ = {'x': x, 'y': y, 'age': age}

On 3.10 that costs 280 B per tree, 280 MB for the forest. It’s also how every Python object worked until 3.2.

The dict you actually get

A normal Tree on 3.10 costs 152 B. The difference is that every tree has the same three attribute names, so since 3.3 (PEP 412, “key-sharing dictionaries”), CPython stores the names once per class and keeps only the values per instance:

Tree instance, 48 B
┌───────────────────────┐
│ GC header        16 B │
│ refcount          8 B │
│ type              8 B │──► Tree class
│ __dict__          8 B │──┐   │ ht_cached_keys
│ __weakref__       8 B │  │   ▼
└───────────────────────┘  │   shared keys: x, y, age
                           │   ▲
dict object, 64 B          │   │
┌───────────────────────┐  │   │
│ GC header        16 B │◄─┘   │
│ refcount, type   16 B │      │
│ used, version    16 B │      │
│ keys pointer      8 B │──────┘
│ values pointer    8 B │──┐
└───────────────────────┘  │
                           │
values array, 40 B         │
┌───────────────────────┐  │
│ →1 │ →2 │ →3 │ · │ ·  │◄─┘
└───────────────────────┘

Going through it:

  • The instance (48 B) is all bookkeeping. There’s a GC header so the cycle collector can track the object, a refcount, a pointer to its class, and two more pointers:
    • __dict__ points to the instance’s dict.
    • __weakref__ heads the list of weak references to this tree (weakref.ref, WeakValueDictionary and friends). It stays NULL until someone makes one, but every instance pays 8 B for the possibility.
  • The dict object (64 B) is mostly header too. Its two interesting fields are the keys pointer and the values pointer, ma_keys and ma_values in CPython’s source (ma_ is a leftover from when dicts were called “mapping objects”).
  • The values array (40 B) is a separate allocation with room for five values, three of them used. It doesn’t hold the ints themselves: each entry is a pointer to an int object somewhere else on the heap. That’s true of every attribute in every layout in this post.
  • The keys table lives on the class, in a field called ht_cached_keys (ht for heap type, CPython’s name for a class created at runtime). Every instance’s dict points at that one table, so it costs nothing per tree.

48 + 64 + 40 = 152 B per tree. Sharing keys saved 128 B on every object, because the part every tree has in common is stored once and only the part that differs is stored per tree. Which raises a question: if the class already knows the attribute names, why does each instance need a dict at all?

__slots__: no dict at all

class Tree:
    __slots__ = ('x', 'y', 'age')

    def __init__(self, x, y, age):
        self.x = x
        self.y = y
        self.age = age

__slots__ tells Python the complete list of attributes up front. It then stores the attributes’ pointers straight in the object at fixed offsets, with no __dict__ and no __weakref__. Here’s Tree(1, 2, 3):

Tree instance, 56 B          offset
┌───────────────────────┐
│ GC header        16 B │     -16
├───────────────────────┤ ◄── id(tree) points here
│ refcount          8 B │       0
│ type              8 B │       8  ──► Tree class
│ age               8 B │      16  ──► 3
│ x                 8 B │      24  ──► 1
│ y                 8 B │      32  ──► 2
└───────────────────────┘

Two details hide in there:

  • The GC header sits in front of the object. id(tree) and every pointer to the tree point past it, so CPython counts offsets from the refcount. From the start of the allocation, x is at byte 40.
  • The slots are sorted. CPython sorts the names when it creates the class, so age comes first, whatever order you wrote them in.

Finding an attribute is the class’s job now. Tree.x is a member_descriptor that knows “x is at offset 24”, so tree.x reads the pointer at that offset and follows it. The class holds the layout and the instance holds only values. That’s the same split key-sharing makes, taken all the way.

56 B on every version we tested. On 3.10 that’s a million trees in 56 MB instead of 152 MB, and reads are about 10% faster.

What it costs you:

  • No new attributes. tree.tag = 1 raises AttributeError: 'Tree' object has no attribute 'tag' and no __dict__ for setting new attributes.
  • No weak references. weakref.ref(tree) raises TypeError: cannot create weak reference to 'Tree' object. You can buy the pointer back by adding '__weakref__' to __slots__.
  • Subclasses must declare __slots__ too. A subclass without them quietly gets a __dict__ back, and with it the cost of a plain class.
  • No class-level defaults. x = 0 next to __slots__ = ('x',) raises ValueError: 'x' in __slots__ conflicts with class variable.
  • Multiple inheritance gets awkward. Two bases that both have non-empty slots can’t be combined: multiple bases have instance lay-out conflict.
  • functools.cached_property doesn’t work: it needs a __dict__ to cache into.

Pickling, copy and dataclasses all handle slots fine.

What 3.11+ does for you

Here’s the same million trees across versions, in bytes per object:

variant3.103.113.123.133.14
own dict (TreeOwnDict)280240232256264
plain class15296889696
__slots__5656565656

and nanoseconds per attribute read:

variant3.103.113.123.133.14
plain class13.25.44.34.74.5
__slots__11.84.94.24.54.9

Three changes produced this.

3.11: no dict until someone asks for one. A plain object no longer gets a dict object at creation. The values array hangs directly off the object, through two hidden pointers placed in front of the refcount, next to the GC header (together, the “pre-header”). A real dict is built only if code reads obj.__dict__. Most code never does, so the 64 B dict object is simply gone: 152 → 96 B.

3.12: one pointer doing two jobs. The 3.11 pre-header holds two pointers, one to the values array and one to the dict, but only one of them is ever in use: the values pointer while there’s no dict, the dict pointer once it’s been built. So 3.12 keeps a single pointer and uses its lowest bit to say which kind it is. That bit is free because objects are always at least 8-byte aligned, so a real pointer to one always ends in zero bits. The freed word takes the __weakref__ pointer, which moves out of the object, and every tree loses 8 B: 88 B.

3.13: values inside the object. The values array is no longer a separate allocation. It’s laid out inline, right after the type pointer, which is why getsizeof stops counting it:

Tree instance, 96 B, one allocation (3.13)
┌───────────────────────┐
│ GC header        16 B │
│ __weakref__       8 B │  ← pre-header
│ __dict__          8 B │  ← NULL until someone reads it
├───────────────────────┤ ◄── id(tree) points here
│ refcount          8 B │
│ type              8 B │
│ values           48 B │  ← pointers to 1, 2, 3, and room to grow
└───────────────────────┘

The total is back to 96 B, because the inline area is sized for the class’s shared keys, with room to spare. But it’s one allocation instead of two, and the values sit right next to the header in memory.

And the speed. 3.11’s specializing interpreter (PEP 659) watches each attribute-access instruction. When it keeps seeing the same class, it rewrites the instruction into a version that skips the lookup and reads the value at the known index. Slots get the same treatment. Plain-class reads became about 2.5× faster than 3.10, and they’re now within noise of slots.

So on 3.11+ the plain class has closed most of the memory gap and all of the speed gap, and it kept every feature __slots__ takes away. What’s left is 96 vs 56 B: slots are still about 40% smaller.

Where the fast path ends

“For free” holds as long as your objects look the way CPython expects. Two things break it.

Reading __dict__

vars(obj), obj.__dict__, and any serializer or debugger that does it for you, build the dict that 3.11+ skipped. Each object you touch gains a 64 B dict object:

bytes per object3.103.113.123.133.14
plain class15296889696
plain, after obj.__dict__ read152160152160160

Worse, on 3.11 and 3.12 its attribute reads stay slow afterwards:

ns per attribute read3.103.113.123.133.14
plain class13.25.44.34.74.5
plain, after obj.__dict__ read13.116.316.44.74.6

That’s about 3× slower than before the read on 3.11 and 3.12, slower even than 3.10, because the fast instruction only works while there’s no dict, and the interpreter falls back to a slower path through the dict. 3.13 fixed the speed (the new dict just points at the inline values) but not the memory.

Wide classes

The keys table on the class has a fixed maximum size. Once a class sets more attributes than fit, every instance falls back to a full dict of its own:

bytes per object3.103.113.123.133.14
29 attributes448320312320320
30 attributes4481640163219041904
30 attributes, __slots__272272272272272

One more attribute and each instance costs five to six times as much, far worse than 3.10 handled the same class. Slots don’t care: 272 B either way.

Thirty sounds like a lot, but it’s easy to reach: an ORM row for a wide table, a settings or config object, a dataclass mirroring an API response or a CSV record with one attribute per column, a game entity with position, velocity, health, inventory and a dozen flags.

So, should you still use __slots__?

On 3.11+, for most classes, no. A plain class is about as fast, costs 96 B instead of 56 B, and keeps dynamic attributes, weakrefs, defaults, cached_property, and easy multiple inheritance.

Reach for __slots__ when:

  • you really have millions of small objects of the same shape, and 40% of their memory matters,
  • the class is wide (30+ attributes), or
  • something reads __dict__ on your hot objects and you’re stuck on 3.11/3.12.

If you’re using dataclasses, it’s one word: @dataclass(slots=True) (3.10+) generates the __slots__ for you.

Or don’t make objects at all

Even a slots tree is mostly overhead, and its attributes are still pointers. They have to be: a Python attribute can hold any object, so it stores a pointer to one. If every x is just an int, you can skip the objects and keep one NumPy array per attribute, with the raw ints packed side by side:

import numpy as np

i = np.arange(1_000_000)
x, y, age = i % 100, i % 200, i % 50
age += 1        # every tree gets a year older

That’s 24 B per tree, the 24 MB from the start. Whole-column operations also run in C:

age every tree, 3.13ns per tree
plain class, Python loop14.6
__slots__, Python loop13.5
NumPy, age += 10.20

The catch: reading one value from Python with age[k] creates a new Python object each time, about 8× slower than tree.age, and there’s no Tree left to put methods on. Columns win when you work on the whole forest at once. Pandas applies the same idea to tables.

What’s next

Everything here makes each tree smaller, or stops making trees. The remaining way, making fewer objects, is the flyweight pattern. That’s part 2.

See it yourself

uv run --python 3.10 measure.py
uv run --python 3.13 measure.py

The full script: measure.py (raw). It declares NumPy as a dependency at the top, and uv run installs it for you.

The slot layout, read straight out of memory:

>>> import ctypes
>>> class Tree:
...     __slots__ = ('x', 'y', 'age')
>>> t = Tree()
>>> t.x, t.y, t.age = 'X', 'Y', 'AGE'
>>> [ctypes.py_object.from_address(id(t) + off).value for off in (16, 24, 32)]
['AGE', 'X', 'Y']
>>> Tree.__basicsize__          # refcount + type + 3 slots; getsizeof adds the 16 B GC header
40

References: PEP 412 (key-sharing dictionaries), PEP 659 (specializing adaptive interpreter), __slots__ in the data model, CPython’s object layout notes.