Python slots - brief comparison
Slots is a way in Python to customize attribute access. Some people love them, some people don't. In my work experience - I didn't really see them too often. If a code was critical enough that this kind of memory savings mattered then it was written in something else and then leveraged by Python, be it as an extension or an entirely separate component.
Recently a friend of mine who has never heard of them, after my brief explanation, asked: "Okay, but how much do they actually save?" and... I couldn't really answer. In this article, I will try to do exactly that!
This article was not written by AI.
AI was however used to spot typos, check grammar errors and generate some code for graphs (not the data!).
Models: DeepSeek V4 Pro
Tool: Opencode v1.2.27
What slots are?
As mentioned, slots are one of the ways a programmer can customize accessing attributes of a class. In Python, if you assign a field to an instance of your class, you do not get an error - it's part of its dynamic nature (and probably a bit uncomfortable coming in from strictly typed languages). The error can happen only if you ask for a field that does not exist.
class M:
pass
obj = M()
obj.test = 1
print(obj.test)
>>>1
print(obj.doesnt_exist)
Traceback (most recent call last):
File "<python-input-4>", line 1, in <module>
print(obj.doesnt_exist)
^^^^^^^^^^^^^^^^
AttributeError: 'M' object has no attribute 'doesnt_exist'
This works because of how Python stores variables on your object. They live in a dictionary field __dict__. That field is populated whenever you assign a field to Python object.
>>> obj.__dict__.keys()
dict_keys(['test'])
>>> obj.__dict__.values()
dict_values([1])
This is both extremely powerful and frightening. For example foreign functions can change the state of your object however they like and you probably won't notice, but at the same time, they might care a little less about what kind of an object you are sending as long as they can assign some fields. Flexible!
So how can you define slots and what do they actually do? When Python sees __slots__ definition a few things happen:
- Python reserves a space in memory for all attributes defined
- Python prevents creation of
__dict__and__weakref__
class S:
__slots__ = ("x", "y")
def __init__(self):
self.x = 1
self.y = 2
s = S()
s.x
>>>1
s.y
>>>2
This has two main advantages:
- It's memory efficient - there is no need to store additional dictionary with your class
- It's faster - direct memory access is faster than dictionary lookups.
But...
>>> s.z = 1
Traceback (most recent call last):
File "<python-input-12>", line 1, in <module>
s.z = 1
^^^
AttributeError: 'S' object has no attribute 'z' and no __dict__ for setting new attributes
Hey! That worked before!
That's true! Because we are not allowing __dict__, we are unable to have dynamic attributes on our Python objects. It's a curse and a blessing.
It's best you stick with using immutable types (such as tuples) for __slots__ declaration as noted by the documentation.
Benchmarking
Now that we know what slots are and what do they do, we can ask a followup question that is the root of this article: How much do they actually save?
We are using python 3.14 on Ubuntu 24.04.4 LTS (Noble Numbat) x86_64. I will be creating an object within a loop and comparing the memory differences by using two snapshots from tracealloc. It's a fantastic debug tool to trace memory blocks. For our purposes, it's much better than a normal sys.getsizeof function.
Essentially, the benchmark will look like this:
def check_memory(hero_class, count):
tracemalloc.start()
snapshot1 = tracemalloc.take_snapshot()
heroes = [hero_class(name="Geralt", level=99, spells=[]) for _ in range(count)]
snapshot2 = tracemalloc.take_snapshot()
stats = snapshot2.compare_to(snapshot1, "lineno")
tracemalloc.stop()
total_mem = sum(stat.size_diff for stat in stats)
return total_mem / 1024 / 1024 # bytes -> KiB -> MiB
The output will always be in MiB(s). Let's compare firstly 4 different classes:
- A standard plain python class
- Same plain python class, but now with
__slots__defined - Python data class of same structure
- Same python class with slots defined
slots=True(which for data classes is equivalent to defining__slots__)
class StandardHero:
def __init__(self, name, level, abilities):
self.name = name
self.level = level
self.abilities = abilities
class SlottedHero:
__slots__ = ("name", "level", "abilities")
def __init__(self, name, level, abilities):
self.name = name
self.level = level
self.abilities = abilities
@dataclass
class DataclassHero:
name: str
level: int
spells: list[int]
@dataclass(slots=True)
class DataclassSlotsHero:
name: str
level: int
spells: list[int]
And here are the results:
But @dataclass only generates code, it does not modify memory footprint or layout. Why even bother comparing plain python objects to them? They are essentially the same..
Correct! We know that. But it's also nice to run experiment to confirm what you already know intuitively. It's a valuable insight - and here, it does show exactly that! The memory efficiency is exactly the same, for both non-slotted, we have a:
- Same
__dict__3 slots in the values array. - Fixed size of instance itself in form of
PyObjectheader + pointer to__dict__
And what have we learned?
- For 10k objects, we are saving 0.40 MiB
- For 100k objects, we are saving 3.82 MiB
- For 1kk objects, we are saving 38.14 MiB
And I didn't show it on chart, but for 10kk, we would be saving around 380 MiB.
Which means we are saving roughly 40 bytes per instance and scaling here is "linear".
Now let's go one step further and add a popular libraries to our benchmark: attrs and pydantic. Since both plain and dataclasses memory footprints are equal, we are going to compare them only to one of them.
We can define attrs classes like so:
@define(slots=False)
class AttrsHero:
name: str
level: int
spells: list[int]
@define(slots=True)
class AttrsSlotHero:
name: str
level: int
spells: list[int]
And our pydantic classes like so:
class PydanticHero(BaseModel):
name: str
level: int
spells: list[int]
@pydantic_dataclasses.dataclass(slots=True)
class PydanticDataclassSlotsHero:
name: str
level: int
spells: list[int]
Hey! Those are Pydantic dataclasses and not their BaseModel!
Yeah, yeah... So unfortunately, Pydantic relies on __dict__ for storing descriptors for validation. Adding __slots__ conflicts with that design, therefore they are not supported at all! If you really need to use __slots__ then the convention is to use their dataclasses or forbid extra fields like so:
class PydanticSlotsHero(BaseModel):
name: str
level: int
spells: list[int]
ConfigDict(extra="forbid", frozen=True)
It is by no means equivalent to what __slots__ do, but for a simple benchmark like this one - it's good enough.
Let's see the results:
So what did we learn from this graph?
- Pydantic... is heavy... 😱 400 MiB more on that small of an object comparing to dataclasses or
attrs.. extra=Forbid, frozen=Truehas no effect on size whatsoever. And why would it? It is only about enforcing immutability of an object, and not about having dedicated mechanism like__slots__.- Dataclasses and
attrsare equal in size but only their non slotted versions. For 1kk objects,attrsslotted version is 16 MiB larger than a slotted dataclass from std and from Pydantic. - Pydantic dataclass is truly equal in size to that of an actual dataclass.
- Size of Pydantic objects matters even for low count of objects.
Conclusion
Slots are relatively simple mechanism to both use and understand. The actual benefit is there and it's easily seen (a so called, low hanging fruit) but it's important to understand the caveats that come with it. Are they worth it - probably not. I haven't had an issue with memory, unless there was a memory leak or another bug. In cases that I did have it, I opted for more powerful mechanisms. Extensions, caching, architectural choices. I think if at one point you are dynamically creating a million objects and storing them in memory for a long time, then there is probably something fundamentally wrong with the idea and one should probably just ask himself "Why?".