Every ordinary Python object carries a hidden __dict__ for storing its instance attributes, which is flexible but not free ā at scale, that per-instance dict is a meaningful memory cost. __slots__ tells Python to skip it, storing attributes in fixed, pre-declared slots instead. This lesson covers the real trade-offs, not just the syntax.
1The Hidden Cost of a Per-Instance __dict__
An ordinary Python class instance stores its attributes in a regular dictionary, instance.__dict__, created automatically the first time you access it (or earlier, depending on implementation details) and attached to every single instance. Dictionaries in CPython are fast and flexible ā average O(1) attribute access, and you can add or remove attributes freely at any time ā but that flexibility has a real, measurable memory cost: an empty dict alone typically takes more memory than the two float fields it might end up holding for a simple Point.
At small scale ā a few dozen objects ā this overhead is irrelevant. At scale ā millions of parsed rows, graph nodes, or simulation particles ā the cumulative cost of one dict per instance becomes the dominant driver of your process's memory footprint, sometimes by a wide margin over the actual data being stored.
__slots__ = ('x', 'y') tells CPython, at class-definition time, exactly which attribute names this class will ever use. Instead of a dict, the interpreter allocates fixed-size storage (conceptually similar to a C struct) sized precisely for those named slots ā eliminating the dict's overhead entirely for every instance of the class.
class PointNoSlots:
def __init__(self, x: float, y: float):
self.x = x
self.y = y
p = PointNoSlots(1.0, 2.0)
print(p.__dict__) # {'x': 1.0, 'y': 2.0} ā a real dict per instanceNo per-instance dict; fixed-size, pre-allocated slots
2The Trade-Off: What You Give Up
The memory savings are not free. The most immediate consequence is that you can no longer set an attribute that wasn't declared in __slots__ ā p.z = 3.0 on a PointSlots instance raises AttributeError rather than silently creating a new attribute. Many teams treat this as a feature rather than a cost, since it catches typos (p.xx = 1.0 instead of p.x = 1.0) and prevents accidental scope creep in a class's shape ā but it does mean you must plan the class's full attribute set up front.
Multiple inheritance with __slots__ gets genuinely tricky: you can't have more than one base class with non-empty __slots__ unless their slot layouts don't conflict, and mixing a slotted class with a non-slotted one reintroduces a __dict__ anyway, silently forfeiting the memory savings you were trying to get. This is also true, less obviously, for subclasses: a subclass that itself doesn't declare __slots__ gets a __dict__ automatically, since Python has to support whatever new attributes that subclass's code might add ā meaning every subclass in the hierarchy has to opt in explicitly for the optimization to hold all the way down.
Weak references (weakref) also don't work by default on slotted classes unless you explicitly add '__weakref__' to __slots__, since the weakref machinery itself normally piggybacks on the instance __dict__.
class PointSlots:
__slots__ = ('x', 'y')
def __init__(self, x: float, y: float):
self.x = x
self.y = y
p = PointSlots(1.0, 2.0)
print(p.x, p.y) # 1.0 2.0
# p.__dict__ would raise AttributeError ā there isn't oneAttributeError ā undeclared attributes are rejected, not silently added
3When the Trade Is Actually Worth Making
__slots__ earns its complexity specifically when you're instantiating large numbers of small, structurally-fixed objects ā parsed records, graph/tree nodes, simulation entities, geometric points ā where the per-instance dict overhead, multiplied across millions of instances, becomes a measurable fraction of total memory usage. Profiling first (with sys.getsizeof, tracemalloc, or a memory profiler) rather than guessing is the professional default; adding __slots__ to a class that's only ever instantiated a handful of times is complexity with no measurable payoff.
For data-holding classes specifically, it's worth noting that @dataclass(slots=True) ā available since 3.10 ā generates the __slots__ declaration automatically from your annotated fields, combining the boilerplate reduction of dataclasses with the memory benefit of slots in one decorator argument, without you needing to keep a separate __slots__ tuple in sync with the field list by hand.
As a rule of thumb: reach for __slots__ (or @dataclass(slots=True)) when profiling has shown per-instance memory is a real bottleneck for a class you're creating in bulk, and skip it everywhere else ā it's an optimization with real behavioral trade-offs, not a default you should apply to every class out of habit.
p = PointSlots(1.0, 2.0)
p.z = 3.0
# AttributeError: 'PointSlots' object has no attribute 'z'Generates __slots__ automatically from annotated fields (3.10+)
4Step-by-Step Breakdown
Creating a million small objects and watching memory usage explode? __slots__ is often the fix ā but it changes how the class behaves, not just how much RAM it uses.
By default, every instance gets its own __dict__ to hold attributes ā flexible, but it costs memory per object, even for a class with just two fixed fields.
__slots__ declares the exact attribute names allowed, and Python stores them in fixed slots instead of a per-instance dict.
Checkpoint: What does __slots__ = ("x", "y") remove from instances of that class?
- āThe per-instance __dict__ used to store arbitrary attributes
- āThe ability to define any methods on the class
That restriction is a feature, not just a side effect: assigning an undeclared attribute raises AttributeError immediately, catching typos and accidental new fields.
Inheritance needs care: a subclass without its own __slots__ silently gets a __dict__ back, undoing the memory savings of the parent class.
Checkpoint: If ColoredPoint(PointSlots) forgets to declare its own __slots__, what happens?
- āColoredPoint instances silently get a __dict__ back, losing the memory benefit
- āPython raises an error at class-definition time
That closes out Modern Python. Next, we go deeper into the language's metaprogramming and iteration machinery with Advanced Python ā starting with Decorators.
Remove a Real Instance __dict__. Finish check: __slots__ replaces the per-instance __dict__ with fixed storage.
Level Up š
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Best Practices
Profile before optimizing with __slots__
Memory savings only matter when a class is instantiated at real scale. Measure with tracemalloc or sys.getsizeof first; don't add __slots__ to every class by default.
Prefer @dataclass(slots=True) over hand-written __slots__ for data classes
It keeps the slots list automatically in sync with the annotated fields, eliminating an easy-to-forget manual maintenance step whenever a field is added or removed.
Frequent Bugs
Adding __slots__ to a base class but forgetting to declare __slots__ (even as an empty tuple) on every subclass, silently losing the memory optimization down the hierarchy.
Declare __slots__ explicitly on every class in the hierarchy that should stay slotted, even if a particular subclass adds no new attributes (__slots__ = () is valid and keeps the chain slotted).
Real-World Examples
Memory-Efficient Graph Nodes for a Large Dataset
A graph-processing tool needs to hold millions of lightweight node objects in memory simultaneously to run a traversal algorithm, where per-instance memory overhead directly limits the maximum graph size that fits in RAM.
class GraphNode:
__slots__ = ('id', 'neighbors', 'visited')
def __init__(self, node_id: int):
self.id = node_id
self.neighbors: list[int] = []
self.visited = False