By default, Python allocates a __dict__ for every instance of a class. Flexible, yes. Cheap, no. When you hold thousands or millions of objects in memory at once, that dictionary overhead adds up fast. __slots__ removes it and replaces per-instance storage with compact internal descriptors. Typical result: 40 to 60 percent less memory per instance.
What Python does without slots
Without any declaration, each instance carries its own __dict__:
class Point:
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
p = Point(1.0, 2.0)
print(p.__dict__) # {'x': 1.0, 'y': 2.0}
This dictionary allows adding attributes at runtime:
p.z = 3.0 # works
p.label = "A" # also works
That flexibility comes at a cost. An empty Python dict takes several hundred bytes (184 bytes on Python 3.12, 232 on Python 3.8), and every single instance pays that price.
slots: removing the dict
Declaring __slots__ tells Python the exact set of attributes an instance is allowed to have. Python skips the __dict__ allocation entirely and uses slot descriptors instead:
class Point:
__slots__ = ("x", "y")
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
p = Point(1.0, 2.0)
print(hasattr(p, "__dict__")) # False
Trying to set an undeclared attribute raises AttributeError immediately:
p.z = 3.0 # AttributeError: 'Point' object has no attribute 'z'
Measuring the real gain
Here is a concrete benchmark using tracemalloc:
import tracemalloc
class WithoutSlots:
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
class WithSlots:
__slots__ = ("x", "y")
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
N = 100_000
tracemalloc.start()
without = [WithoutSlots(i, i) for i in range(N)]
_, peak_without = tracemalloc.get_traced_memory()
tracemalloc.stop()
tracemalloc.start()
with_slots = [WithSlots(i, i) for i in range(N)]
_, peak_with = tracemalloc.get_traced_memory()
tracemalloc.stop()
print(f"Without __slots__: {peak_without / 1_000_000:.1f} MB")
print(f"With __slots__: {peak_with / 1_000_000:.1f} MB")
Typical output on Python 3.12:
Without __slots__: 28.3 MB
With __slots__: 9.6 MB
Around 66 percent reduction. The exact number varies by attribute count and type.
When to use slots
Large numbers of instances held in memory simultaneously. Classic cases: data objects for simulations, graph or tree nodes, events in a processing pipeline, records bulk-loaded from an API or a flat file.
Preventing accidental attribute creation. On value objects and DTOs, forbidding dynamic attributes eliminates a subtle bug class: a typo on an attribute name that silently creates a new attribute instead of modifying the intended one.
Attribute access speed. Slot access via descriptor is marginally faster than hash-table lookup through __dict__. The difference rarely matters in a typical web request, but it shows up on tight benchmarks or hot inner loops.
The pitfalls worth knowing
Inheritance: parent classes without slots
If a parent class has no __slots__, its instances carry a __dict__, and subclasses inherit it even if they declare their own slots:
class Base:
pass # no __slots__ → has __dict__
class Child(Base):
__slots__ = ("x",)
c = Child()
c.surprise = "still works" # Base has no __slots__, so its instances have __dict__, which Child inherits
For the memory benefit to hold across a hierarchy, every class in the chain must declare its own __slots__ without inheriting from a class that has __dict__.
Framework incompatibilities
Several libraries expect a __dict__ on instances: some ORMs, serialization frameworks, and certain pickle or deep-copy implementations depending on their version. Test compatibility before applying __slots__ to classes used with third-party frameworks.
weakref disappears too
Instances with __slots__ lose weak reference support by default. If you need it, add "__weakref__" explicitly to the list:
class Node:
__slots__ = ("value", "next", "__weakref__")
dataclasses and slots
Since Python 3.10, @dataclass accepts slots=True directly:
from dataclasses import dataclass
@dataclass(slots=True)
class Coordinate:
x: float
y: float
z: float
This is the cleanest way to use __slots__ on modern data classes. No manual declaration, no risk of the slot list going out of sync with the fields.
Summary
| Without slots | With slots | |
|---|---|---|
| Attribute storage | Per-instance __dict__ | Internal descriptors |
| Dynamic attribute addition | Yes | No |
| Memory per instance | Higher | 40–60% less |
| Attribute access speed | Slightly slower | Slightly faster |
| Framework compatibility | Universal | Must verify |
| Multi-level inheritance | Simple | Requires discipline |
__slots__ is not the first optimization to reach for. It is a precision tool for situations where memory has been measured and documented as a real problem. Hundreds of objects in a typical web application: negligible impact. Millions of objects in memory during a pipeline run or a simulation: one of the most direct levers available in pure Python.
