Type something to search...
__slots__ in Python: Saving Memory in Attribute-Heavy Classes

slots in Python: Saving Memory in Attribute-Heavy Classes

Most Python objects carry a small dictionary around with them. It's what lets you do obj.anything = 42 on almost any instance, and it's convenient. It also costs memory, and when you create hundreds of thousands or millions of small objects (points, graph nodes, parsed records, game entities) that cost adds up.

__slots__ is a class attribute that tells Python up front which attributes an instance will have. Python then stores those attributes in fixed positions inside the object instead of in a per-instance dictionary. The result is smaller objects and a class that rejects typos in attribute names.

In this post I'll show what __slots__ actually changes, measure the savings on Python 3.13 (they're smaller than many older articles suggest), and walk through the rules and gotchas: inheritance, default values, weak references, dataclass(slots=True), pickling, and cached_property.

How Normal Instances Store Attributes

By default, every instance of a class gets an attribute dictionary, exposed as __dict__:

class PointDict:
    def __init__(self, x: float, y: float) -> None:
        self.x = x
        self.y = y


p = PointDict(1, 2)
print(p.__dict__)  # {'x': 1, 'y': 2}
p.label = "origin-ish"  # any new attribute is allowed

That flexibility is why you can monkeypatch objects, add attributes in tests, and why tools like vars() work. Modern CPython is clever about it: since 3.11, instances of the same class share key storage and the dictionary object itself is only created when something asks for __dict__. But there's still per-instance space reserved for the values, plus bookkeeping to support adding arbitrary new attributes.

Declaring __slots__

Add a __slots__ attribute listing the attribute names, usually as a tuple:

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

    def __init__(self, x: float, y: float) -> None:
        self.x = x
        self.y = y

The class works exactly as before for x and y. What changes:

s = PointSlots(1, 2)
print(hasattr(s, "__dict__"))

s.z = 3
False
AttributeError: 'PointSlots' object has no attribute 'z' and no __dict__ for setting new attributes

There's no __dict__, and you can only set the attributes you declared. That second part is a nice side benefit: a typo like self.lenght = 5 in a method raises immediately instead of silently creating a new attribute.

What Python Does with Slots

For each name in __slots__, Python creates a descriptor on the class that reads and writes a fixed offset inside the instance:

print(PointSlots.x)
print(type(PointSlots.__dict__["x"]))
<member 'x' of 'PointSlots' objects>
<class 'member_descriptor'>

When you access s.x, attribute lookup finds this descriptor on the class, and it fetches the value directly from the instance's memory layout. That's the same descriptor mechanism behind properties and methods, which the descriptors post explains in detail.

Measuring the Savings

sys.getsizeof() is misleading for this comparison, because it doesn't account for the dictionary storage in a consistent way across versions. A more honest measure is tracemalloc: create many instances and see how much memory was allocated.

# measure_slots.py
import tracemalloc


class PointDict:
    def __init__(self, x: float, y: float) -> None:
        self.x = x
        self.y = y


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

    def __init__(self, x: float, y: float) -> None:
        self.x = x
        self.y = y


def bytes_per_instance(cls, n: int = 100_000) -> float:
    tracemalloc.start()
    objects = [cls(0.0, 0.0) for _ in range(n)]
    current, _ = tracemalloc.get_traced_memory()
    tracemalloc.stop()
    return current / n


for cls in (PointDict, PointSlots):
    print(f"{cls.__name__:<11} {bytes_per_instance(cls):5.0f} bytes per instance")

On Python 3.13 (64-bit macOS), I get:

PointDict      96 bytes per instance
PointSlots     56 bytes per instance

Each figure includes 8 bytes for the list slot that points at the object, and the same float object is reused for every instance, so this isolates the cost of the instances themselves. That's roughly a 40% saving: about 40 bytes per object. For 10 million points, that's around 400 MB.

The regular class gets worse if anything touches __dict__, because that forces CPython to create a real dictionary for the instance. In the same test, reading o.__dict__ on every object pushed PointDict to 160 bytes per instance. Code that calls vars(obj), serializes via obj.__dict__, or adds attributes dynamically will trigger this.

Exact numbers depend on Python version and platform. Older versions (3.10 and earlier) always created the dictionary, so slots saved considerably more there. Measure on the version you deploy.

What About Speed?

Attribute access on slotted instances is sometimes described as much faster. On current CPython, the difference is negligible:

import timeit

setup = """
class D:
    def __init__(self): self.x = 1
class S:
    __slots__ = ("x",)
    def __init__(self): self.x = 1
d = D(); s = S()
"""
for name in ("d", "s"):
    t = min(timeit.repeat(f"{name}.x", setup=setup, number=10_000_000, repeat=5))
    print(name, f"{t:.3f}")
d 0.067
s 0.065

Both are around 6.5 nanoseconds per access. CPython's specialising interpreter (3.11+) already makes regular attribute access fast. Use __slots__ for memory, not speed.

Slots and Inheritance

This is where most __slots__ surprises come from.

Every Class in the Chain Needs Slots

If any class in the hierarchy lacks __slots__, instances get a __dict__ anyway, and the memory saving disappears:

class Base:
    pass


class Child(Base):
    __slots__ = ("a",)


c = Child()
c.anything = 1                 # works: Base provides a __dict__
print(hasattr(c, "__dict__"))  # True

The same thing happens in the other direction. A subclass of a slotted class that doesn't declare __slots__ gets a __dict__:

class SBase:
    __slots__ = ()


class SChild(SBase):
    __slots__ = ("a",)


class Sub(SChild):
    pass


print(hasattr(SChild(), "__dict__"))  # False
print(hasattr(Sub(), "__dict__"))     # True

So to keep the savings, every class in the chain declares __slots__. A base class with no attributes of its own uses an empty tuple, __slots__ = (). Subclasses list only their new attributes; repeating a parent's slot wastes space and hides the parent's descriptor.

Multiple Inheritance

Two parent classes that both define non-empty slots can't be combined:

class A:
    __slots__ = ("a",)


class B:
    __slots__ = ("b",)


class C(A, B):
    pass
# TypeError: multiple bases have instance lay-out conflict

Each parent defines a fixed memory layout, and Python can't merge two of them. The usual fix is to make mixin-style classes use __slots__ = () and put the actual slots in one concrete class.

Default Values and Class Attributes

You can't set a class attribute with the same name as a slot, which rules out the common "default value on the class" trick:

class Defaults:
    __slots__ = ("x",)
    x = 0
# ValueError: 'x' in __slots__ conflicts with class variable

Set defaults in __init__ instead. And remember that an unset slot doesn't exist at all until you assign it:

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


U().x
# AttributeError: 'U' object has no attribute 'x'

Weak References and __dict__ Opt-Ins

A slotted class also loses the __weakref__ slot by default, so you can't create weak references to its instances:

import weakref


class NoWeak:
    __slots__ = ("x",)


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

If you need weak references (for caches built on weakref.WeakValueDictionary, for example), add "__weakref__" to the slots:

class Weak:
    __slots__ = ("x", "__weakref__")

You can even add "__dict__" to __slots__ to allow dynamic attributes while keeping fixed slots for the common ones. That gives up much of the memory saving, so it's rarely worth it.

dataclass(slots=True)

Writing __slots__ by hand duplicates your attribute list. Since Python 3.10, dataclasses can generate it for you, and they handle default values correctly:

from dataclasses import dataclass


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


p = Point(1.0)
print(p, Point.__slots__, hasattr(p, "__dict__"))
p.z = 1
Point(x=1.0, y=0.0) ('x', 'y') False
AttributeError: 'Point' object has no attribute 'z' and no __dict__ for setting new attributes

Notice y = 0.0 works here even though a plain slotted class would raise the class-variable conflict. The dataclass decorator builds a new class with the slots and moves the defaults into the generated __init__. You can combine it with frozen=True for small immutable value objects, which is a very common pattern. One gotcha: because the decorator creates a new class, methods that use zero-argument super() still point at the original class. On Python 3.13, calling super().f() inside a @dataclass(slots=True) subclass raises TypeError: super(type, obj): obj (instance of C) is not an instance or subtype of type (C). Use the explicit form super(C, self).f() there, or check whether your Python version has fixed it. See Dataclasses in Python for the rest of the dataclass options.

attrs (with @define, which uses slots by default) and Pydantic models have their own approaches, so check their docs rather than adding __slots__ manually.

Other Things Slots Change

  • vars() stops working. vars(obj) raises TypeError: vars() argument must have __dict__ attribute. Code that serializes objects through __dict__ needs a different approach, such as dataclasses.asdict() or iterating over __slots__.
  • functools.cached_property needs a __dict__. It stores the cached value in the instance dictionary, so on a slotted class you get TypeError: No '__dict__' attribute on 'Cached' instance to cache 'sq' property. Compute the value in __init__, use a regular @property, or add a dedicated slot and cache into it manually.
  • Pickling works. With the default pickle protocol, slotted objects pickle and unpickle without extra code.
  • Monkeypatching instances doesn't. Tests that set attributes on instances (obj.fake_method = ...) will fail. Patch at the class level instead.

When to Use __slots__

Slots are worth it when:

  • You create a large number of instances of the class, at least tens of thousands alive at once.
  • The set of attributes is fixed and known.
  • The class is a leaf or a small hierarchy you control, so you can add slots at every level.

They're usually not worth it for classes with a handful of instances (services, configuration objects, views), classes that rely on dynamic attributes, or classes deep in a framework hierarchy you don't control. The memory saving on one object is about 40 bytes, so it only matters in aggregate.

When you do need them, profile first. If the bulk of your memory is the values (large strings, lists, nested dicts) rather than the objects holding them, slots won't help much. And if you have millions of uniform numeric records, a NumPy array or a columnar structure will beat any per-object optimisation.

Conclusion

__slots__ replaces the per-instance attribute dictionary with fixed storage for a declared set of attributes. On Python 3.13 that cut a two-attribute object from about 88 to 48 bytes in my measurement, rejects misspelled attributes, and leaves attribute access speed essentially unchanged.

The price is flexibility: no dynamic attributes, no vars(), no cached_property, no weak references unless you ask for them, and a requirement that every class in the hierarchy cooperates. For most new code, @dataclass(slots=True) is the easiest way to get the benefit without the boilerplate. Reach for it when you're creating objects by the hundred thousand, and measure before and after.

Tags :
Share :

Related Posts

Abstract Base Classes in Python with the abc Module

Abstract Base Classes in Python with the abc Module

Python leans on duck typing: if an object has the method you need, you call it and move on. That works well until you have a family of classes that a

Continue Reading
*args and **kwargs in Python: Flexible Function Signatures

*args and **kwargs in Python: Flexible Function Signatures

You've seen def wrapper(*args, **kwargs): in decorators, and probably super().__init__(**kwargs) in class hierarchies. These two parameters let a

Continue Reading
Asyncio in Python: A Beginner's Guide to Asynchronous Programming

Asyncio in Python: A Beginner's Guide to Asynchronous Programming

A lot of programs spend most of their time waiting. A web scraper waits for pages to download, an API server waits for the database, a chat bot waits

Continue Reading