Skip to main content

Optimizing Classes with `__slots__`

OOP Modern Tools

Using __slots__

`__slots__` tells Python to allocate a fixed set of attributes, eliminating the per-instance `__dict__`.

What __slots__ does

By default every instance carries a __dict__ — a real dictionary holding its attributes. That's what makes Python objects dynamic (obj.anything = value always works) and it's also a per-instance memory cost. Declaring __slots__ replaces the dict with fixed storage for exactly the named attributes:

class LightPoint:
__slots__ = ('x', 'y', 'z')

def __init__(self, x: float, y: float, z: float):
self.x = x
self.y = y
self.z = z

Instances of LightPoint have no __dict__; the three attributes live in preallocated slots, which also makes attribute lookup slightly faster. The attribute set is now closed:

p = LightPoint(1.0, 2.0, 3.0)
p.w = 4.0
# AttributeError: 'LightPoint' object has no attribute 'w'

That AttributeError is the contract working — anything not listed in __slots__ can't be assigned. (Python 3.12+ extends the message with "and no __dict__ for setting new attributes".)

What you actually save

sys.getsizeof on the instance alone understates the difference — the dict is a separate object, so count it too:

import sys

class DictPoint:
def __init__(self):
self.x = self.y = self.z = 1.0

class SlotPoint:
__slots__ = ('x', 'y', 'z')
def __init__(self):
self.x = self.y = self.z = 1.0

d, s = DictPoint(), SlotPoint()
print(sys.getsizeof(d) + sys.getsizeof(d.__dict__)) # -> 152 (48 + 104)
print(sys.getsizeof(s)) # -> 56

Those numbers are CPython 3.10 on 64-bit Linux; exact figures shift between versions, but the shape holds: for a small class, a slotted instance is roughly a third the size, because the per-instance dict (plus its allocation overhead) disappears. Across ten million points that's on the order of a gigabyte saved. For a handful of instances it's nothing.

Gotchas

A subclass without __slots__ silently reintroduces __dict__ — and with it, the memory cost and dynamic attributes:

class Slotted:
__slots__ = ('x',)

class Child(Slotted): # no __slots__ here
pass

c = Child()
c.anything = 'goes' # works again
print(c.__dict__) # -> {'anything': 'goes'}

Every class in the hierarchy must declare __slots__ (listing only its new attributes, possibly ()) for the savings to survive. Multiple inheritance is stricter still: at most one base may carry nonempty slots.

A slot can't share its name with a class attribute, so the "class-level default" idiom fails at class creation:

class Broken:
__slots__ = ('x',)
x = 5
# ValueError: 'x' in __slots__ conflicts with class variable

Set defaults in __init__ instead.

Weak references need their own slot. The default __weakref__ machinery lives outside __slots__, so add it explicitly if anything weak-references your instances:

import weakref

class NoRef:
__slots__ = ('x',)

weakref.ref(NoRef())
# TypeError: cannot create weak reference to 'NoRef' object

class Ref:
__slots__ = ('x', '__weakref__')

r = Ref()
print(weakref.ref(r)() is r) # -> True

If you genuinely need occasional dynamic attributes, adding '__dict__' to __slots__ restores them — at which point most of the memory benefit is gone.

When slots matter

Reach for __slots__ when instance count is the problem: millions of points, tree nodes, cache entries, parsed records. Below that scale it's premature — you trade away dynamic attributes, complicate inheritance, and save kilobytes. Profile first (tracemalloc tells you where memory actually goes), then slot the one or two classes that dominate.

The easy path: dataclass(slots=True)

On Python 3.10+, dataclasses generate the slots from the field list, so the declaration can't drift out of sync with __init__:

from dataclasses import dataclass

@dataclass(slots=True) # Python 3.10+
class Reading:
sensor: str
value: float

r = Reading('t1', 21.5)
print(r) # -> Reading(sensor='t1', value=21.5)
r.unit = 'C'
# AttributeError: 'Reading' object has no attribute 'unit'

For new code that qualifies as data-holding, this is the recommended way to get slots.

Frequently Asked Questions

How much memory do slots actually save?

It depends on attribute count and Python version, so distrust any single number. The saving is the per-instance dict: for a small class on CPython 3.10 that turns roughly 150 bytes per instance into roughly 56 — about 3x. Modern CPython already shares key storage between instance dicts, so real-world gains can be smaller. It only matters multiplied by a large instance count; measure with tracemalloc before and after.

Can I still add attributes dynamically with __slots__?

No — that restriction is the mechanism itself. Assigning any attribute not named in __slots__ raises AttributeError, because there is no __dict__ to put it in. If you need both slots and occasional dynamic attributes, include '__dict__' in __slots__, but that reinstates the per-instance dict and forfeits most of the savings.

class Flexible:
__slots__ = ('x', '__dict__')

f = Flexible()
f.x = 1
f.extra = 2 # allowed again, stored in the restored __dict__

Should I just use __slots__ on every class?

No. Most classes have few instances, and for them slots add constraints (closed attribute set, all-or-nothing inheritance, weakref and multiple-inheritance wrinkles) for negligible benefit. Default to normal classes; apply slots deliberately to the small number of types you instantiate in bulk — or get them free with @dataclass(slots=True) where a dataclass fits anyway.

Next up in your learning path