__slots__: what it changes in the object model and what it costs in bytes
The mechanism first: the declared names get data descriptors on the class and places at fixed offsets, while __dict__ and __weakref__ stop being created automatically. All of that is the language contract, identical on 3.11–3.14. Only then the price, which is not part of the contract: 40 bytes per instance, 42% — while sys.getsizeof reports the opposite, basicsize stays silent about __weakref__, and the sum of two getsizeof calls answers differently at the start of a program than later on.
Full technical treatment
TL;DR
What it does. __slots__ declares attribute names up front. Each name gets
a data descriptor on the class and a place of its own in the instance, while
__dict__ and __weakref__ stop being created automatically. That is the
language contract: identical on 3.11–3.14.
What follows. No new attributes; cached_property does not work; a weak
reference needs an explicit '__weakref__'; a subclass without its own
__slots__ gets a dict back. But the prohibition is not absolute: '__dict__'
can be named among the slots and dynamic attributes come back.
How much it saves is not a property of the language but of the implementation. On a class with three attributes: 40 bytes per instance (42%) on 3.11, 3.13 and 3.14, and 32 bytes on 3.12.
The familiar numbers do not show this. Since 3.12 sys.getsizeof answers
with the sign reversed, and adding it to the size of the dict gives one answer
at the start of a program and another later. The measurement has to be done on a
batch.
Slots do not give speed. On the measured build, warmed reads and writes with
and without slots differ by a few per cent. But vars(obj) is expensive in
memory and in time at once.
What __slots__ changes
A plain instance can be given attributes at any time, so it needs somewhere for
names to appear at run time — a dict. __slots__ changes that premise: the
names are declared up front and room for them is reserved up front.
class Plain:
pass
p = Plain()
p.later = "anything, at any moment"
class Slotted:
__slots__ = ("x", "y")
s = Slotted()
s.x = 1
s.z = 3 # AttributeErrorThe refusal on the last line is not a separate rule but a consequence: no room
was reserved for z, and there is no dict to put arbitrary things in.
A slot is a descriptor on the class
Declaring a slot puts an object into the class dict:
class User:
__slots__ = ("name",)
User.__dict__["name"] # <member 'name' of 'User' objects>It has both __get__ and __set__ — so it is a data descriptor, and it
outranks the instance dict. The dot does nothing of its own: it invokes this
descriptor. The same picture explains why cached_property does not work with
slots: it is a non-data descriptor and relies on the instance dict shadowing
it, and there is nothing to shadow it with.
Three configurations
| fixed slots | __dict__ | new attributes | |
|---|---|---|---|
| a plain class | no | yes | yes |
__slots__ = (...) | yes | no | no |
__slots__ = (..., "__dict__") | yes | yes | yes |
The third row is the one usually left out. It has a trap of its own: vars(obj)
shows only the dynamic names — slot values never enter the dict.
Inheritance
One rule: a dict is allocated for whoever did not declare __slots__.
class Child(Base): # the dict is back
pass
class ChildOk(Base): # the saving is kept
__slots__ = ()An empty tuple is not “no slots”, it is “no slots of my own, and do not allocate a dict”.
How much that is in bytes
The numbers are about specific builds, not about the language. 3.13.13, a class
with three attributes. On the left, what sys.getsizeof answers; on the right,
the bytes actually requested while creating 200,000 instances:
getsizeof | measured | |
|---|---|---|
without __slots__ | 48 B | 96 B |
with __slots__ | 56 B | 56 B |
| conclusion | slots cost 8 more | slots cost 40 less |
getsizeof counts only the object itself: the instance dict sits behind a
pointer and does not enter the number. Hence the inversion — the one that pays
less shows the larger number.
Adding the size of the dict does not help either: for the very first instance of a class the dict takes 296 bytes, and for the same class after a thousand others it takes 96. Instances of one class share the keys table (each still has its own dict), and before that sharing happens the number is different. Which is why the measurement is done on a batch.
Slots do not give speed
3.13.13, nanoseconds per operation:
| with slots | without slots | after vars(obj) | |
|---|---|---|---|
reading o.a | 8.78 | 8.51 | 8.53 |
writing o.a = 5 | 8.33 | 8.36 | 32.37 |
The difference between slots and a plain class is about three per cent, and on
reads it does not favour slots. And so it should be: a warmed access specialises
into LOAD_ATTR_SLOT for slots and into LOAD_ATTR_INSTANCE_VALUE for a plain
class — two different opcodes doing identical work, a read at a fixed offset.
The third column is not noise. vars(obj) unfolds the values into a real dict:
memory grows from 96 to 160 bytes and writes get nearly four times dearer.
When to use it
When there are many instances: a million objects is 40 megabytes, a hundred is
four kilobytes not worth discussing. And when it matters that other people's
code cannot materialise a dict through vars(): with slots there is nothing to
materialise.
This topic has two storeys, and nearly all the confusion around it comes from collapsing them into one.
The first storey is the language contract. __slots__ declares names; each
name gets a data descriptor on the class and a place of its own in the instance;
__dict__ and __weakref__ stop being created automatically. This does not
change between versions, and code can rely on it.
The second storey is the implementation. How many bytes that saves, what
sys.getsizeof reports, where the dict physically sits. Nothing here is
guaranteed, and between 3.11 and 3.14 it changed three times.
The article works upwards: the mechanism first, then what the language promises, then how CPython implements it, and only then the numbers. The numbers come last not because they matter least — they are why the article exists — but because without the first three parts they read as a pile of surprises.
Part I. What __slots__ is
Why a plain instance needs a dict
In Python, attributes can be added at any time and with any name:
class Plain:
pass
p = Plain()
p.x = 1
p.later = "anything, at any moment"For that to work, an instance needs somewhere for names to appear at run time —
a dynamic namespace. That is what __dict__ is for.
__slots__ changes exactly that premise: the names are declared up front, and
room for them is reserved up front.
class Slotted:
__slots__ = ("x", "y")
s = Slotted()
s.x = 1
s.y = 2
s.z = 3 # AttributeErrorThe refusal on the last line is not a separate rule saying “slots forbid new
attributes”. It is a consequence: no room was reserved for z, and there is no
dict to put arbitrary things in.
A slot is a data descriptor on the class
It is tempting to stop at the image “the value sits inside the object”. That is true but incomplete, and half the behaviour does not follow from it. More precisely: declaring a slot puts an object into the CLASS dict.
class User:
__slots__ = ("name",)
User.__dict__["name"] # <member 'name' of 'User' objects>That object has both __get__ and __set__ — so it is a data descriptor,
and by the priority rule from the article on attribute lookup it outranks the
instance dict. Everything else follows:
u = User()
u.name = "ann"
u.name # 'ann'
User.__dict__["name"].__get__(u, User) # 'ann' — the same, bypassing the dotThe dot does nothing of its own: it invokes this descriptor. The class holds the knowledge of which offset in the instance the value lives at; the instance holds the value.
The same picture explains at once why cached_property does not work with
slots: it is a non-data descriptor and relies on the instance dict shadowing
it on the next access. There is nothing to shadow it with.
'__dict__' in the slots: the prohibition is not absolute
Between “a plain class” and “slots only” there is a third option that usually goes unmentioned:
class Flexible:
__slots__ = ("a", "b", "__dict__")
f = Flexible()
f.a = 1 # a slot
f.whatever = 2 # a dynamic attribute — accepted
vars(f) # {'whatever': 2}Slots give the declared names a predictable layout, while the dict leaves the door open for the rest.
Note the last line: slot values are not in vars(f). They sit at their own
offsets and the dict knows nothing about them. Hence a consequence worth more
than the feature itself: any code that serialises an object through vars() or
__dict__ — a logger, a debugger, a naive asdict — will not see all the
attributes of such a class.
Inheritance: one rule
There is one rule, and versions do not change it: a dict is allocated for
whoever did not declare __slots__.
class Base:
__slots__ = ("x",)
class ChildBare(Base): # has a dict — no line
pass
class ChildEmpty(Base): # no dict
__slots__ = ()
class ChildOwn(Base): # no dict
__slots__ = ("y",)__dict__ | |
|---|---|
Base | no |
ChildBare — no line | yes |
ChildEmpty — __slots__ = () | no |
ChildOwn — __slots__ = ("y",) | no |
The second and third rows are the whole inheritance trap. __slots__ = () is
not “no slots”, it is “no slots of my own, and do not allocate a dict”.
Part II. What the language promises
Three configurations and what each gives
| fixed slots | __dict__ | dynamic attributes | |
|---|---|---|---|
| a plain class | no | yes | yes |
__slots__ = (...) | yes | no | no |
__slots__ = (..., "__dict__") | yes | yes | yes |
The edge cases people trip over
Weak references do not work until they are declared. weakref.ref on an
instance with slots raises TypeError until '__weakref__' is in the tuple.
The name is special, but it is listed like any other.
Multiple inheritance breaks on two non-empty slot sets.
TypeError: multiple bases have instance lay-out conflict. A slot is a fixed
offset, and two incompatible layouts cannot coexist. If one parent's slots are
empty there is no conflict.
Repeating a slot name in a subclass raises nothing — and that is the worst case.
class A:
__slots__ = ("x",)
class B(A):
__slots__ = ("x",) # the same name — the declaration goes throughB gets its own descriptor x, and the base's room is wasted. The base's
slot then becomes unreachable by ordinary name access: the subclass descriptor
shadows it. It can only be reached through the base's descriptor directly — and
then you can see two different values under one name in one object. The rule is
simple: do not repeat a slot name in a subclass.
Non-empty slots are forbidden not to “built-in types” but to types with a
non-zero __itemsize__. This is not a list to memorise but a criterion you
can check in one line:
| type | __itemsize__ | non-empty slots |
|---|---|---|
tuple | 8 | TypeError |
bytes | 1 | TypeError |
int | 4 | TypeError |
str | 0 | allowed |
list, dict, set, float, object | 0 | allowed |
Such a type stores a variable number of elements inside the object itself, so
there is nowhere to assign a fixed offset for a slot. str is the case where
the familiar list “int, bytes, tuple” gives the wrong answer: it is
variable-length in spirit, but its __itemsize__ is zero and a str subclass
is allowed non-empty slots. Empty slots are allowed to everyone.
The dict form declares attribute documentation. Rare, but useful:
class User:
__slots__ = {
"name": "the user's name",
"age": "age in whole years",
}
inspect.getdoc(User.name) # "the user's name"This is the only way to give slots something they otherwise lack: help() and
documentation tools see a description for each attribute.
Everything listed in this part is identical on 3.11, 3.12, 3.13 and 3.14 — a
run of bench/slots/contract.py on the four builds produces byte-identical
output. What follows is what does not match.
Part III. What CPython does
What getsizeof shows and what a measurement shows
A class with three attributes, on 3.13.13. On the left, what sys.getsizeof
answers; on the right, the bytes actually requested from the allocator while
creating 200,000 such instances:
getsizeof | measured | |
|---|---|---|
without __slots__ | 48 B | 96 B |
with __slots__ | 56 B | 56 B |
| conclusion | slots cost 8 more | slots cost 40 less |
The reason for the gap is written in the function's own documentation:
Only the memory consumption directly attributed to the object is accounted for,
not the memory consumption of objects it refers to.
The instance dict is exactly such a referred-to object. It sits behind a pointer
and does not enter the number. An instance with slots has no dict at all, and
its three values live inside the object itself — so they do enter basicsize.
Hence the inversion: the one that pays less shows the larger number.
On 3.11 the same function does not invert the answer, it stays silent: 56 against 56. It does not give the right answer in any version.
Why the sum of two getsizeof calls is not the answer either
An obvious correction suggests itself: add getsizeof(obj) and
getsizeof(obj.__dict__). It does not work, and unpacking why explains what
key-sharing is along the way.
What is shared is not the dict but the keys table. The dicts of different instances are different objects:
x, y = Plain(), Plain()
x.__dict__ is y.__dict__ # False
x.a = 999
y.__dict__["a"] # 1 — a write to one is not visible in the otherWhat they do share is something else: the attribute names are the same for every instance of the class, so there is no point storing them once per instance. The keys table is therefore one per class, while the values stay per instance.
Hence the consequence that breaks the sum: the size of an instance dict depends on the history of the program, not on the class.
getsizeof(obj.__dict__) | 3.11 / 3.12 / 3.13 / 3.14 |
|---|---|
| for the very first instance of the class | 296 B |
| for the same class after a thousand others | 96 B |
One class, the same three attributes. The naive sum gives 48 + 296 = 344 B at the start of the program and 48 + 96 = 144 B later, while a batch measurement gives 160 B. It overstates by 184 bytes in one case and understates by 16 in the other, and which answer you get depends on when you asked.
Which is why the measurement has to be done on a batch: there the keys are already shared — as they are in a real program, where instances are many.
The layout from version to version
Bytes are comparable across versions: this is object layout, not timing.
| bytes per instance | 3.11.15 | 3.12.3 | 3.13.13 | 3.14.7 |
|---|---|---|---|---|
without __slots__ | 96 | 88 | 96 | 96 |
with __slots__ | 56 | 56 | 56 | 56 |
| saving | 40 | 32 | 40 | 40 |
The series is not monotonic, and that matters: it is usually described as “the saving grew in 3.13”. It grew only relative to 3.12: 3.11 already gave 40 bytes, 3.12 made the plain instance eight bytes cheaper, and 3.13 gave those eight bytes back.
What changed. Before 3.13, a single word covered “dict or values”, tagged as a union:
typedef union {
PyObject *dict;
/* Use a char* to generate a warning if directly assigning a PyDictValues */
char *values;
} PyDictOrValues;In 3.13 the dict pointer and the inline values moved apart: the pointer got its own word in the pre-header, and the values moved inside the object. A plain instance grew by eight bytes; an instance with slots did not change — it has neither.
The inheritance trap in bytes
Versions do not change the rule from Part I. They do change its price:
| bytes per instance | 3.11.15 | 3.12.3 | 3.13.13 | 3.14.7 |
|---|---|---|---|---|
| a plain class | 96 | 88 | 96 | 96 |
| a subclass without its own slots | 96 | 88 | 72 | 96 |
On 3.13 such a subclass is 24 bytes cheaper than a plain class. The difference
reads off a single flag: INLINE_VALUES is unset on it while a plain class has
it. And why it is unset is written in the header:
static inline PyDictValues *
_PyObject_InlineValues(PyObject *obj)
{
assert(Py_TYPE(obj)->tp_flags & Py_TPFLAGS_INLINE_VALUES);
assert(Py_TYPE(obj)->tp_flags & Py_TPFLAGS_MANAGED_DICT);
assert(Py_TYPE(obj)->tp_basicsize == sizeof(PyObject));
return (PyDictValues *)((char *)obj + sizeof(PyObject));
}The third line is the answer. The values are placed at exactly
obj + sizeof(PyObject) — immediately after the header. In a subclass the
base's slots are already there, there is no room, and no values array is
allocated at all: the dict pointer stays empty until something writes to the
dict.
On 3.14 the flag is set on such a subclass and the price returns to 96. There are no 3.14 headers on this machine, so only what a runtime flag reports and what was measured is asserted here: its source was not read.
The practical conclusion does not depend on the version and matches the rule
from Part I: a subclass needs its own __slots__, an empty tuple at least. What
to take from this section is not the numbers but the shape: the price of this
trap is not constant from version to version, and it has to be checked on the
version the code actually runs on.
The price of __weakref__ and the silent basicsize
| 3.11.15 | 3.12.3 | 3.13.13 | 3.14.7 | |
|---|---|---|---|---|
| with slots | 56 B | 56 B | 56 B | 56 B |
and with '__weakref__' | 64 B | 72 B | 72 B | 72 B |
| price | 8 B | 16 B | 16 B | 16 B |
difference in basicsize | 8 | 0 | 0 | 0 |
weaklistoffset | 40 | −32 | −32 | −32 |
From 3.12 the price is twice as high and basicsize reports zero. The reference
moved into the pre-header — the area before the start of the object — and by
definition it does not enter basicsize. The negative weaklistoffset says
exactly that: the offset counts backwards from the start of the object.
The failure is the same one as with getsizeof: the familiar number stays
silent while bytes are spent. A different quantity, the same mechanism — only
what lies inside the object is counted.
Part IV. The measurements
How the numbers were obtained
tracemalloc around the creation of a batch of 200,000 instances: it counts
every requested allocation, not the size of one header. Dividing by the batch
size gives the price of an instance, and allocator noise — arenas, pools,
rounding — averages out over a batch that size.
Three things are deliberate, and without them the numbers cannot be trusted.
The attribute values are shared. Every instance is assigned the same pre-created objects, so not a single allocation per instance goes to a value. Give each of them its own three strings built at runtime and the same batch reports 254.4 bytes instead of 96 — the 158.4-byte difference is memory for strings.
The holding list is allocated in full before tracemalloc starts. So it
never enters the measurement and there is nothing to subtract. The check
measures a batch of an already-created object and must return zero — and it
does.
The value must not depend on the batch size. 200,000 and 400,000 give the same number: the discrepancy is 0.00 bytes.
Does __slots__ speed up access
Checked inside one run of one interpreter — timing is never compared across versions, because the builds have different compilers and different flags.
3.13.13, nanoseconds per operation, best of seven rounds:
| with slots | without slots | after vars(obj) | |
|---|---|---|---|
reading o.a | 8.78 | 8.51 | 8.53 |
writing o.a = 5 | 8.33 | 8.36 | 32.37 |
The difference between slots and a plain class is 3.1% on reads and 0.4% on writes, and on reads it runs against slots. On this build and this class, the measurement shows no advantage for slots in access speed.
The wording is deliberately narrow. What was checked: one build, a class with
three attributes, warmed reads and writes of a single attribute. Concluding
“__slots__ does not speed up access” in general would repeat the very mistake
this article criticises getsizeof for — taking the result of a particular
measurement for a property of the language.
What the warmed read specialises into
This is why the timings could not have differed. A warmed operation in modern CPython is executed not by the generic opcode but by a specialised one, and the adaptive disassembler shows which:
| 3.12 | 3.13 | 3.14 | |
|---|---|---|---|
| with slots | LOAD_ATTR_SLOT | LOAD_ATTR_SLOT | LOAD_ATTR_SLOT |
| without slots | LOAD_ATTR_INSTANCE_VALUE | LOAD_ATTR_INSTANCE_VALUE | LOAD_ATTR_INSTANCE_VALUE |
after vars(obj) | LOAD_ATTR_WITH_HINT | LOAD_ATTR_INSTANCE_VALUE | LOAD_ATTR |
The first two rows are different opcodes doing identical work: a read at a fixed offset inside the object. Different routes arriving at the same thing, so the timings match. The measurement was not comparing “a descriptor against a dict” but two specialisations.
The third row explains the third column of the timing table and shows how much
of this is a property of the build: on 3.14 no specialisation survives
vars(obj) at all — the generic LOAD_ATTR runs, with the whole attribute
lookup protocol.
Hence the precise statement of what was measured: not “which mechanism is shorter in principle” but “what a warmed operation costs in this build after the interpreter's optimisations”.
Why the operation is repeated in that measurement
The first draft timed a single o.a per timeit loop iteration. The run prints
both regimes side by side, and here is the un-amortised one:
| one operation per iteration | ns |
|---|---|
pass | 12.36 |
o.a with slots | 19.44 |
o.a without slots | 19.56 |
Only 36% of the number is signal here; the rest is the loop. The difference between the last two lines sits inside its noise, and a conclusion drawn from it is worth nothing.
Once the operation was repeated 50 times inside an iteration, the floor of the method dropped to 1.43 ns against the eight being measured — and the numbers started meaning what they should. The conclusion did not flip; it firmed up.
This is the third instance of one story. getsizeof measures the wrong thing;
basicsize cannot see the pre-header; a one-operation timeit measures itself.
Each time the tool answers honestly the question it was asked — it just is not
the question that was meant.
What vars(obj) costs
Touching vars(obj) unfolds the inline values into a real dict. On the measured
build that is more expensive both in memory and in time: 96 → 160 bytes, and
writing an attribute costs nearly four times as much (+287%) while reading does
not change.
The ratio matters more than the number. In a different session on the same machine the same place gave +136%: the write still got much more expensive, but by exactly how much is a property of the run. Three consecutive runs gave +296, +298 and +302%, and one of them is what the record holds.
This should be stated as narrowly as the previous conclusion: in CPython with
inline attributes, touching vars(obj) or obj.__dict__ may materialise
the dictionary representation, and that can increase the instance's memory and
change the cost of subsequent writes. By how much depends on the version, the
shape of the class and the workload.
Part V. In practice
When __slots__ is worth it
A 40-byte saving matters where there are many instances: a million objects is 40 megabytes, a hundred is four kilobytes not worth discussing.
The order of questions the decision follows:
- Are there really many instances of one small class? No — slots are probably not needed.
- Are dynamic attributes needed, or tools that work through
__dict__? Yes — a plain class, or__slots__together with'__dict__'. - Are weak references needed? Yes — add
'__weakref__'and count the 16 bytes. - Inheritance? Every subclass needs its own
__slots__, an empty tuple at least. - Check on your own version. The numbers in this article are about four specific builds.
What slots take in exchange: no new attributes; multiple inheritance from two
parents with non-empty slots does not assemble; cached_property does not work;
weakref needs an explicit entry.
And the main reason they are usually chosen: the saving does not evaporate when
somebody else's code touches vars(obj) — a logger, a serialiser, a debugger. A
class with slots has no dict at all, so there is nothing to materialise. A plain
class has no protection from this whatsoever.
Version history
| Version | Change | What this means for code |
|---|---|---|
| 3.11 | A plain instance costs 96 bytes, one with slots 56. Here getsizeof does not yet invert the answer, it merely stays silent: 56 against 56. '__weakref__' in slots costs 8 bytes, and basicsize shows them. | |
| 3.12 | A plain instance drops to 88 bytes and the saving falls to 32. From this version getsizeof answers with the sign reversed: 48 against 56. '__weakref__' doubles to 16 bytes and moves into the pre-header — basicsize stops showing it. A read after vars(obj) specialises into LOAD_ATTR_WITH_HINT. | |
| 3.13 | The Py_TPFLAGS_INLINE_VALUES flag appears; the dict pointer and the inline values move apart. A plain instance returns to 96 bytes and the saving to 40. Side effect: a subclass of a slotted class with no slots of its own does not get inline values and costs 72 bytes — cheaper than a plain class. | |
| 3.14 | The restriction on inline values is lifted: a subclass without its own slots costs 96 bytes again. On the other hand a read after vars(obj) stops specialising at all — the generic LOAD_ATTR remains. |
How this was measured
The numbers in this article come from these scripts. Each opens from here, together with the record of its run.
The language contract, without bytes or timing — the output matches on all four builds:
Bytes, flags and offsets:
Timing and specialisation:
Python 3.11.15, 3.12.3 and 3.13.13 (GCC 13.3.0), 3.14.7 (Clang 22.1.3); Intel Xeon 2.80 GHz, 2 vCPU. Bytes are comparable across versions — this is object layout. Timing is not: the builds have different compilers and different flags.
The header fragments were read from the files of the installed builds,
/usr/include/python3.12/internal/pycore_object.h and
/usr/include/python3.13/internal/pycore_object.h, and are printed verbatim by
the run. There are no 3.14 headers on this machine: about that version only what
was read from a runtime flag and measured is asserted here.
This is neither a retelling nor a separate text: everything below is taken from the article itself — its own summary, the section headings, the “actually” column and the version table. Which is why these theses cannot drift from the article.
In fact
- Since 3.12 it shows the opposite: 48 bytes for an instance without slots against 56 with them, so by that number slots cost 8 bytes more. The real saving is 40 bytes. The reason is stated in the function's own documentation: Only the memory consumption directly attributed to the object is accounted for, not the memory consumption of objects it refers to. The instance dict is exactly such a referred-to object. On 3.11 the function does not invert the answer, it merely stays silent: 56 against 56. It does not give the right answer in any version.
- On the measured build, no. 3.13.13, reading
o.a: 8.78 ns with slots against 8.51 without; writing: 8.33 against 8.36. About three per cent, and on reads it runs against slots. The disassembler shows why: a warmed access specialises intoLOAD_ATTR_SLOTfor slots and intoLOAD_ATTR_INSTANCE_VALUEfor a plain class — two different opcodes doing identical work, a read at a fixed offset. So the measurement was not comparing “a descriptor against a dict” but two specialisations, and it answers “what a warmed operation costs in this build”, not “which mechanism is shorter in principle”. The gain from slots is in memory. - The series is not monotonic: 96 / 88 / 96 / 96 bytes on 3.11.15, 3.12.3, 3.13.13 and 3.14.7 for a plain instance, so the saving is 40 / 32 / 40 / 40. 3.11 already gave 40 bytes; 3.12 made a plain instance eight bytes cheaper and 3.13 gave them back, moving the dict pointer and the inline values apart. “The saving grew in 3.13” is true only relative to 3.12.
- It does not: without its own
__slots__it gets a dict back. And the price of that trap differs by version — 96 / 88 / 72 / 96 bytes — so on 3.13 such a subclass is unexpectedly 24 bytes cheaper than a plain class. The cause is a restriction written inpycore_object.houtright:assert(Py_TYPE(obj)->tp_basicsize == sizeof(PyObject));— inline values are placed immediately after the object header, where the base's slots already are, so no values array is allocated at all. On 3.14 the flag is set on such a subclass and the price returns to 96 — that comes from a runtime flag and a measurement; the 3.14 source was not read. The fix is the same in every version:__slots__ = ()on the subclass. - The memory changes, not
basicsize. From 3.12 a weak reference costs 16 bytes per instance (56 → 72), whilebasicsizeshows zero difference: the reference moved into the pre-header, before the start of the object, andbasicsizecounts only what is inside. The negativeweaklistoffset(−32) is where that shows. On 3.11 it cost 8 bytes andbasicsizeshowed them. - It does not. The figures “10% to 20%” come from PEP 412 and are about key-sharing dictionaries, not
__slots__: measurements show a memory saving of 10% to 20% for object-oriented programs. No official figure for the saving from slots exists at all, which is why every number here is an own measurement with the method stated. - What is shared is not the dict but the keys table. The dicts of different instances are different objects:
x.__dict__ is y.__dict__givesFalse, and a write to one is not visible in the other. What they share is something else — the attribute names are the same for every instance of the class, so there is no point storing them once per instance; the values stay per instance. Hence the consequence that makes adding twogetsizeofcalls useless: the size of an instance dict depends on whether the keys have been shared yet. For the very first instance of a class it is 296 bytes; for the same class after a thousand others, 96. One class, three attributes, and the answer depends on when you asked. - Almost all of them allow it. The prohibition applies exactly when
__itemsize__ != 0— that is, to types storing a variable number of elements inside the object:tuple(8),bytes(1),int(4). Forstr,list,dict,set,floatandobjectthe itemsize is zero and non-empty slots are allowed. The familiar list “int, bytes, tuple” gives the wrong answer forstr: a string is variable-length in spirit, but its itemsize is zero. Empty slots are allowed to everyone. - The declaration goes through without an error, and that is the worst case: nothing signals the problem. The subclass gets its OWN descriptor under the same name, the base's room is wasted, and the base's slot becomes unreachable by ordinary access. Reaching it through the base's descriptor directly shows two different values under one name in one object. The rule: do not repeat a slot name in a subclass.
- The exact opposite. An empty tuple says “no slots of my own, and do not allocate a dict” — the instance stays without a
__dict__. Omitting the line says “allocate a dict”. The difference shows in__dictoffset__: for a subclass with__slots__ = ()it is zero, for a subclass without the line it is not.
By version
- 3.11
- A plain instance costs 96 bytes, one with slots 56. Here
getsizeofdoes not yet invert the answer, it merely stays silent: 56 against 56.'__weakref__'in slots costs 8 bytes, andbasicsizeshows them.< - 3.12
- A plain instance drops to 88 bytes and the saving falls to 32. From this version
getsizeofanswers with the sign reversed: 48 against 56.'__weakref__'doubles to 16 bytes and moves into the pre-header —basicsizestops showing it. A read aftervars(obj)specialises intoLOAD_ATTR_WITH_HINT.< - 3.13
- The
Py_TPFLAGS_INLINE_VALUESflag appears; the dict pointer and the inline values move apart. A plain instance returns to 96 bytes and the saving to 40. Side effect: a subclass of a slotted class with no slots of its own does not get inline values and costs 72 bytes — cheaper than a plain class.< - 3.14
- The restriction on inline values is lifted: a subclass without its own slots costs 96 bytes again. On the other hand a read after
vars(obj)stops specialising at all — the genericLOAD_ATTRremains.<
What is covered
- Part I. What `__slots__` is
- Why a plain instance needs a dict
- A slot is a data descriptor on the class
- `'__dict__'` in the slots: the prohibition is not absolute
- Inheritance: one rule
- Part II. What the language promises
- Three configurations and what each gives
- The edge cases people trip over
- Part III. What CPython does
- What getsizeof shows and what a measurement shows
- Why the sum of two getsizeof calls is not the answer either
- The layout from version to version
- The inheritance trap in bytes
- The price of `__weakref__` and the silent basicsize
- Part IV. The measurements
- How the numbers were obtained
- Does `__slots__` speed up access
- What the warmed read specialises into
- Why the operation is repeated in that measurement
- What `vars(obj)` costs
- Part V. In practice
- When `__slots__` is worth it
- Version history
- How this was measured
Common misconceptions
sys.getsizeof will show how much __slots__ saves
Since 3.12 it shows the opposite: 48 bytes for an instance without slots against 56 with them, so by that number slots cost 8 bytes more. The real saving is 40 bytes. The reason is stated in the function's own documentation: Only the memory consumption directly attributed to the object is accounted for, not the memory consumption of objects it refers to
. The instance dict is exactly such a referred-to object. On 3.11 the function does not invert the answer, it merely stays silent: 56 against 56. It does not give the right answer in any version.
__slots__ speeds up attribute access
On the measured build, no. 3.13.13, reading o.a: 8.78 ns with slots against 8.51 without; writing: 8.33 against 8.36. About three per cent, and on reads it runs against slots. The disassembler shows why: a warmed access specialises into LOAD_ATTR_SLOT for slots and into LOAD_ATTR_INSTANCE_VALUE for a plain class — two different opcodes doing identical work, a read at a fixed offset. So the measurement was not comparing “a descriptor against a dict” but two specialisations, and it answers “what a warmed operation costs in this build”, not “which mechanism is shorter in principle”. The gain from slots is in memory.
The saving from slots grew from version to version
The series is not monotonic: 96 / 88 / 96 / 96 bytes on 3.11.15, 3.12.3, 3.13.13 and 3.14.7 for a plain instance, so the saving is 40 / 32 / 40 / 40. 3.11 already gave 40 bytes; 3.12 made a plain instance eight bytes cheaper and 3.13 gave them back, moving the dict pointer and the inline values apart. “The saving grew in 3.13” is true only relative to 3.12.
A subclass of a slotted class inherits the saving
It does not: without its own __slots__ it gets a dict back. And the price of that trap differs by version — 96 / 88 / 72 / 96 bytes — so on 3.13 such a subclass is unexpectedly 24 bytes cheaper than a plain class. The cause is a restriction written in pycore_object.h outright: assert(Py_TYPE(obj)->tp_basicsize == sizeof(PyObject)); — inline values are placed immediately after the object header, where the base's slots already are, so no values array is allocated at all. On 3.14 the flag is set on such a subclass and the price returns to 96 — that comes from a runtime flag and a measurement; the 3.14 source was not read. The fix is the same in every version: __slots__ = () on the subclass.
Adding '__weakref__' to slots is free: basicsize does not change
The memory changes, not basicsize. From 3.12 a weak reference costs 16 bytes per instance (56 → 72), while basicsize shows zero difference: the reference moved into the pre-header, before the start of the object, and basicsize counts only what is inside. The negative weaklistoffset (−32) is where that shows. On 3.11 it cost 8 bytes and basicsize showed them.
The official documentation says slots save 10–20%
It does not. The figures “10% to 20%” come from PEP 412 and are about key-sharing dictionaries, not __slots__: measurements show a memory saving of 10% to 20% for object-oriented programs
. No official figure for the saving from slots exists at all, which is why every number here is an own measurement with the method stated.
Under key-sharing, instances share one common dict
What is shared is not the dict but the keys table. The dicts of different instances are different objects: x.__dict__ is y.__dict__ gives False, and a write to one is not visible in the other. What they share is something else — the attribute names are the same for every instance of the class, so there is no point storing them once per instance; the values stay per instance. Hence the consequence that makes adding two getsizeof calls useless: the size of an instance dict depends on whether the keys have been shared yet. For the very first instance of a class it is 296 bytes; for the same class after a thousand others, 96. One class, three attributes, and the answer depends on when you asked.
Non-empty __slots__ cannot be declared on a subclass of a built-in type
Almost all of them allow it. The prohibition applies exactly when __itemsize__ != 0 — that is, to types storing a variable number of elements inside the object: tuple (8), bytes (1), int (4). For str, list, dict, set, float and object the itemsize is zero and non-empty slots are allowed. The familiar list “int, bytes, tuple” gives the wrong answer for str: a string is variable-length in spirit, but its itemsize is zero. Empty slots are allowed to everyone.
Repeating a slot name in a subclass is harmless — it is just an override
The declaration goes through without an error, and that is the worst case: nothing signals the problem. The subclass gets its OWN descriptor under the same name, the base's room is wasted, and the base's slot becomes unreachable by ordinary access. Reaching it through the base's descriptor directly shows two different values under one name in one object. The rule: do not repeat a slot name in a subclass.
__slots__ = () is the same as not writing __slots__ at all
The exact opposite. An empty tuple says “no slots of my own, and do not allocate a dict” — the instance stays without a __dict__. Omitting the line says “allocate a dict”. The difference shows in __dictoffset__: for a subclass with __slots__ = () it is zero, for a subclass without the line it is not.
Knowledge check
A class has three attributes. sys.getsizeof reports 48 bytes for an instance without __slots__ and 56 with them. What follows from that?
Sources & further reading
5 SOURCES
- internal/pycore_object.h — inline values in 3.13CPython source code. The place the subclass-without-slots behaviour follows from. `_PyObject_InlineValues` opens with three assertions, the third being `assert(Py_TYPE(obj)->tp_basicsize == sizeof(PyObject));`, after which the values are addressed as `(PyDictValues *)((char *)obj + sizeof(PyObject))`. The values array is placed immediately after the object header, so a type that already has the base's slots there does not get one. Read from the installed build's header, `/usr/include/python3.13/internal/pycore_object.h`.https://github.com/python/cpython/blob/3.13/Include/internal/pycore_object.h
- internal/pycore_object.h — the same place in 3.12CPython source code. For comparison: before 3.13 a single word covered “dict or values”, tagged as a union — `typedef union { PyObject *dict; char *values; } PyDictOrValues;`, with the comment `/* Use a char* to generate a warning if directly assigning a PyDictValues */`. In 3.13 the dict pointer and the inline values moved apart, and a plain instance grew by 8 bytes. Read from the installed build's header, `/usr/include/python3.12/internal/pycore_object.h`.https://github.com/python/cpython/blob/3.12/Include/internal/pycore_object.h
- object.h — type flag bitsCPython source code. `Py_TPFLAGS_INLINE_VALUES (1 << 2)` is defined only from 3.13 on; in the `object.h` of 3.11 and 3.12 that bit does not exist at all. Nearby are `Py_TPFLAGS_MANAGED_WEAKREF (1 << 3)` (from 3.12) and `Py_TPFLAGS_MANAGED_DICT (1 << 4)`. This is why asking “is INLINE_VALUES set” on 3.11 is meaningless: there it is a different, unnamed bit.https://github.com/python/cpython/blob/3.13/Include/object.h
- sys.getsizeof — what exactly is returnedOfficial documentation. The official wording behind the whole confusion: “Only the memory consumption directly attributed to the object is accounted for, not the memory consumption of objects it refers to.” An instance dict is precisely an object that is referred to, and it does not enter the number.https://docs.python.org/3/library/sys.html#sys.getsizeof
- PEP 412 — Key-Sharing DictionaryPEP. The source of the mechanism that makes measuring an instance's memory non-trivial: instances of one class share the KEYS TABLE while the values stay per-instance. It is also where the only official figure about memory savings usually quoted next to slots comes from — and it is about key-sharing dictionaries, not `__slots__`: “measurements show a memory saving of 10% to 20% for object-oriented programs”. No official figure for the saving from `__slots__` exists at all.https://peps.python.org/pep-0412/