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,WeakValueDictionaryand 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_keysandma_valuesin 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(htfor 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,xis at byte 40. - The slots are sorted. CPython sorts the names when it creates the class,
so
agecomes 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 = 1raisesAttributeError: 'Tree' object has no attribute 'tag' and no __dict__ for setting new attributes. - No weak references.
weakref.ref(tree)raisesTypeError: 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 = 0next to__slots__ = ('x',)raisesValueError: '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_propertydoesn’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:
| variant | 3.10 | 3.11 | 3.12 | 3.13 | 3.14 |
|---|---|---|---|---|---|
own dict (TreeOwnDict) | 280 | 240 | 232 | 256 | 264 |
| plain class | 152 | 96 | 88 | 96 | 96 |
__slots__ | 56 | 56 | 56 | 56 | 56 |
and nanoseconds per attribute read:
| variant | 3.10 | 3.11 | 3.12 | 3.13 | 3.14 |
|---|---|---|---|---|---|
| plain class | 13.2 | 5.4 | 4.3 | 4.7 | 4.5 |
__slots__ | 11.8 | 4.9 | 4.2 | 4.5 | 4.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 object | 3.10 | 3.11 | 3.12 | 3.13 | 3.14 |
|---|---|---|---|---|---|
| plain class | 152 | 96 | 88 | 96 | 96 |
plain, after obj.__dict__ read | 152 | 160 | 152 | 160 | 160 |
Worse, on 3.11 and 3.12 its attribute reads stay slow afterwards:
| ns per attribute read | 3.10 | 3.11 | 3.12 | 3.13 | 3.14 |
|---|---|---|---|---|---|
| plain class | 13.2 | 5.4 | 4.3 | 4.7 | 4.5 |
plain, after obj.__dict__ read | 13.1 | 16.3 | 16.4 | 4.7 | 4.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 object | 3.10 | 3.11 | 3.12 | 3.13 | 3.14 |
|---|---|---|---|---|---|
| 29 attributes | 448 | 320 | 312 | 320 | 320 |
| 30 attributes | 448 | 1640 | 1632 | 1904 | 1904 |
30 attributes, __slots__ | 272 | 272 | 272 | 272 | 272 |
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.13 | ns per tree |
|---|---|
| plain class, Python loop | 14.6 |
__slots__, Python loop | 13.5 |
NumPy, age += 1 | 0.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.