backend / python core / 28_slots.md

__slots__ — when it saves memory and what it breaks

2 min read source

__slots__ — when it saves memory and what it breaks

__slots__ declares a fixed set of attributes for a class, replacing the per-instance __dict__ with a compact array of slots.

What it does

class Point:
    __slots__ = ("x", "y")

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

p = Point(1, 2)
p.x = 10            #
p.z = 3             # AttributeError: 'Point' object has no attribute 'z'
p.__dict__          # AttributeError: 'Point' object has no attribute '__dict__'

Without slots:

class Point2:
    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point2(1, 2)
p.z = 3             # — instance dict accommodates anything
p.__dict__          # {'x': 1, 'y': 2, 'z': 3}

Memory impact

The instance dict is the biggest contributor to per-object memory in non-slotted classes. For 1M instances, that’s ~64 MB just in dict overhead.

Quick comparison with pympler or sys.getsizeof:

import sys

class A:
    pass

class B:
    __slots__ = ("x", "y")

a = A(); a.x = 1; a.y = 2
b = B(); b.x = 1; b.y = 2

sys.getsizeof(a) + sys.getsizeof(a.__dict__)   # ~152 bytes
sys.getsizeof(b)                                # ~48 bytes

Roughly 2-3x memory savings for small data classes with many instances. Useful for: graph nodes, particle systems, ORM rows, trading order books, ML feature vectors.

Attribute access is also slightly faster (slot is a fixed offset, dict requires hash lookup).

What slots break

  • No dynamic attributes. instance.new_attr = ... fails for anything not in slots.
  • No __dict__. Code that introspects via vars(obj) or obj.__dict__ breaks.
  • Pickling needs care — default pickling uses __dict__; slotted classes need __getstate__/__setstate__ or rely on pickle’s slot support.
  • Multiple inheritance is restricted — at most one base class with non-empty __slots__. Otherwise: TypeError: multiple bases have instance lay-out conflict.
  • @cached_property doesn’t work without explicitly adding the attribute name to __slots__.
  • Class-level defaults conflict__slots__ = ("x",) with x = 5 at class level fails (slot descriptor masks the default).

Subclassing

If a slotted class is subclassed by a non-slotted class, the subclass instances do get a __dict__ (slots don’t propagate as a constraint, only as a memory layout hint).

class A:
    __slots__ = ("x",)

class B(A):   # no __slots__ declared
    pass

b = B()
b.x = 1       # from slot
b.y = 2       # B has __dict__

To keep memory savings in subclasses, declare __slots__ = () (empty) in the subclass.

With @dataclass

Python 3.10+:

from dataclasses import dataclass

@dataclass(slots=True)
class Point:
    x: int
    y: int

Equivalent to writing __slots__ = ("x", "y") manually.

When to use

  • Many instances (tens of thousands+) → big memory win
  • Class is a value object / data container → no need for dynamic attrs anyway
  • Performance-critical attribute access in tight loops

When NOT to use

  • Few instances → negligible benefit, lose flexibility
  • Dynamic plugins / mixins that monkey-patch instances
  • You need pickling without thinking about it
  • Heavy multiple inheritance

Interview angle

“What does __slots__ do?” (Replaces __dict__ with a fixed array — saves memory.) Follow-up: “What are the trade-offs?” (No dynamic attrs, MI restrictions, pickling caveats.) Senior follow-up: “When would you use it?” (High-instance-count value classes — graph nodes, time-series records.)