__slots__ is commonly described as a memory optimization that removes __dict__. That is directionally useful and mechanically incomplete. A slots declaration asks the class machinery to create descriptors for named pieces of instance storage. Whether an instance also has a dictionary, supports weak references, or saves meaningful memory depends on the entire inheritance hierarchy and workload.

Slots change the shape and extensibility of objects. That makes them a data-model decision first and an optimization second. The right question is not "are slots faster?" It is "does this stable record shape justify the capabilities I remove, and does measurement show the footprint matters?"

Python guarantee. A valid __slots__ declaration reserves space for declared attributes and normally prevents automatic creation of __dict__ and __weakref__ for instances of that class. Slot names become class-level descriptors.

Version note. Examples were run on 64-bit, standard GIL-enabled CPython 3.14.7. Exact byte sizes, descriptor types, memory layout, and access performance are CPython/build details. Python 3.11 added inherited-slot handling that avoids creating duplicate storage for names already slotted by a base; do not inspect __slots__ to discover all fields.

Experiment 1: slots restrict ordinary instance shape

Pyodide / WebAssembly
class Point:
    __slots__ = ("x", "y")

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


point = Point(2, 3)
print("[state] point coordinates:", point.x, point.y)
print("[check] point has __dict__:", hasattr(point, "__dict__"))

try:
    point.label = "origin-ish"
except AttributeError as error:
    print("[error] arbitrary attribute assignment:", type(error).__name__)

The declared assignments work; an arbitrary new name does not. This catches misspelled attributes at runtime and communicates a fixed shape, but it can break serializers, debugging tools, ORMs, and test fixtures that expect to attach state through __dict__.

Slots do not freeze values. point.x = 9 remains legal. They do not enforce types, make instances immutable, provide value equality, or automatically reduce the number of Python objects referenced by fields. Those are separate concerns.

Experiment 2: a slot is a descriptor on the class

import types


class Point:
    __slots__ = ("x",)


descriptor = Point.__dict__["x"]
point = Point()
descriptor.__set__(point, 42)

print(type(descriptor).__name__)
print(isinstance(descriptor, types.MemberDescriptorType))
print(descriptor.__get__(point, Point))

On CPython this reports a member_descriptor. Attribute access participates in the same descriptor precedence rules as properties and methods. The language promises slot descriptors, not CPython's descriptor type name or C-level offset representation.

Do not replace a slot descriptor with a class attribute of the same name after creation. Doing so hides the descriptor and can make the reserved storage inaccessible through normal lookup. Defaults should be assigned in __init__, not by overwriting slot names in the class body.

Measure both object and dictionary

sys.getsizeof(instance) alone produces a misleading comparison. For an ordinary user class, much of the variable attribute storage lives in a separate dictionary. A fair shallow comparison includes that dictionary. It still excludes referred-to field values, allocator fragmentation, class objects, and shared key tables.

Experiment 3: compare shallow storage honestly

import sys


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


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

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


regular = Regular(1, 2)
slotted = Slotted(1, 2)

regular_shallow = sys.getsizeof(regular) + sys.getsizeof(regular.__dict__)
slotted_shallow = sys.getsizeof(slotted)

print(regular_shallow, slotted_shallow)
print(regular_shallow > slotted_shallow)

The inequality is the lesson; exact numbers depend on architecture, build, class population, and key-sharing state. One ordinary instance's dictionary may appear larger than dictionaries after many instances have warmed the shared shape. Measure thousands of representative live objects with the same fields and lifecycle.

If referenced values dominate memory, saving a few pointers per record will not transform the service. Conversely, millions of tiny coordinate or syntax-node objects can make fixed per-instance overhead decisive. Profile peak resident memory and allocation behavior, not only one shallow object.

Inheritance decides whether a dictionary returns

A slotted base does not force all subclasses to remain slotted. A subclass without __slots__ receives the usual instance dictionary and weak-reference support. Declare empty slots to promise that a subclass adds no storage.

Experiment 4: an unslotted subclass restores flexibility

Pyodide / WebAssembly
class Base:
    __slots__ = ("identifier",)


class Flexible(Base):
    pass


class Fixed(Base):
    __slots__ = ()


flexible = Flexible()
flexible.identifier = 1
flexible.extra = 2

fixed = Fixed()
fixed.identifier = 3

print("[state] flexible instance dictionary:", flexible.__dict__)
print("[check] fixed instance has __dict__:", hasattr(fixed, "__dict__"))

The first instance contains extra in its dictionary while inherited identifier still uses base slot storage. The second remains dictionary-free. A memory review must inspect concrete leaf classes, not merely notice slots on a base.

Multiple inheritance adds constraints. Python permits at most one base to contribute a non-empty instance layout in many combinations; incompatible slotted layouts raise TypeError. Empty-slot mixins are safer because they contribute behavior without competing storage.

Experiment 5: incompatible layouts are rejected

class Left:
    __slots__ = ("left",)


class Right:
    __slots__ = ("right",)


try:
    class Both(Left, Right):
        __slots__ = ()
except TypeError as error:
    print(type(error).__name__)
    print("layout" in str(error))

CPython 3.14's message mentions layout conflict; message text is not an API. The failure reflects implementation constraints allowed by the object model. Avoid designing independent stateful mixins around slots. Put state in one concrete lineage or use contained helper objects.

Weak references require explicit storage

Ordinary user-defined instances usually support weak references. A slotted declaration suppresses the implicit weak-reference field. Include the special name __weakref__ if caches, observers, weakref.finalize, or other infrastructure must refer to instances weakly.

Experiment 6: opt in to weak references

Pyodide / WebAssembly
import weakref


class Closed:
    __slots__ = ("value",)


class Observable:
    __slots__ = ("value", "__weakref__")


closed = Closed()
observable = Observable()

try:
    weakref.ref(closed)
except TypeError as error:
    print("[error] weak reference to Closed:", type(error).__name__)

reference = weakref.ref(observable)
print("[check] weak reference resolves to observable:", reference() is observable)

Adding __weakref__ has a storage cost, but omitting it can make a class unusable with otherwise appropriate lifetime tools. This is an API capability decision, not trivia to discover after deployment.

Similarly, add __dict__ to __slots__ when selected fixed fields should coexist with arbitrary attributes. That hybrid retains dictionary cost once dynamic fields appear, though fixed slot values remain outside it.

Experiment 7: a hybrid object has both stores

Pyodide / WebAssembly
class Event:
    __slots__ = ("kind", "__dict__")


event = Event()
event.kind = "created"
event.request_id = "r-17"

print("[state] slotted event kind:", event.kind)
print("[state] dynamic event attributes:", event.__dict__)
print("[check] slotted kind stored in __dict__:", "kind" in event.__dict__)

kind is managed by its descriptor; request_id goes into the dictionary. This can support extension metadata while reserving common fields, but it weakens the typo-detection and maximum-footprint arguments for slots.

Dataclasses can generate slots

@dataclass(slots=True) returns a new class with generated slots. With weakref_slot=True, it also adds weak-reference support; using that option without slots is an error. Because a new class is returned, code that captures the undecorated class during creation hooks can encounter subtle identity surprises.

Experiment 8: generated slots preserve data-class features

Pyodide / WebAssembly
from dataclasses import dataclass, fields


@dataclass(slots=True, weakref_slot=True)
class Reading:
    sensor: str
    value: float


reading = Reading("north", 18.5)
print("[state] reading:", reading)
print("[result] dataclass field names:", [field.name for field in fields(Reading)])
print("[result] generated slots:", Reading.__slots__)

Dataclass fields are the supported source of field metadata. __slots__ is not: inherited fields may be omitted, and a declaration may be any non-iterator iterable rather than one canonical tuple. Use dataclasses.fields, annotations, or a framework's documented reflection API.

Pickling, copying, and tooling

The standard pickle and copy machinery supports many straightforward slotted classes, but custom state code that assumes self.__dict__ does not. Libraries may inspect both dictionaries and slot descriptors, require explicit adapters, or reject slots entirely. Test the exact serializer and framework version before changing a persisted or public class.

Slots are also an inheritance contract. Adding them to an existing class can break subclasses that multiply inherit, consumers that attach attributes, weak-reference users, and pickled data assumptions. Treat conversion as an API migration, not an invisible speed patch.

Attribute access may benchmark somewhat faster on a particular CPython version because storage is direct and specialization differs. That is not a Python guarantee, and a microbenchmark does not establish application impact. CPython's specializing interpreter also optimizes common dictionary-backed attribute patterns. Choose slots for measured aggregate benefit and model fit.

Engineering guidance

Good candidates have numerous live instances, a stable tiny field set, controlled inheritance, and measured per-instance overhead. Internal AST nodes, geometry records, and simulation entities often qualify. Long-lived public domain objects, framework entities, plugin bases, and objects routinely decorated with request metadata often do not.

Before adopting slots, inventory dynamic attribute use, weak references, serialization, mocks, debuggers, subclassing, and multiple inheritance. Benchmark representative object counts. Include complete shallow stores and process-level memory. Keep a regular class if savings are immaterial; ordinary instance dictionaries already benefit from CPython key sharing.

If immutability is the goal, use a frozen data class or guarded assignment while understanding its guarantees. If compact homogeneous numbers are the goal, arrays or packed structures can outperform millions of boxed slotted objects. Slots optimize one object model; they do not turn Python records into C structs.

Migration and compatibility checklist

Changing an existing class to slots deserves a consumer audit. Search for writes to attributes outside initialization, direct access to __dict__, vars(instance), weak references, pickling hooks, mocking that injects collaborators, and subclasses outside your repository. Type annotations do not prove that runtime code never adds a field. Instrumenting __setattr__ in a test environment can reveal dynamic writes before migration, although it cannot observe consumers you do not run.

Version persisted representations explicitly. Default pickle behavior may successfully handle a straightforward conversion while old pickles or custom __getstate__ code still assume dictionaries. Round-trip both newly written and representative historical data with the exact supported Python versions. For JSON and schema frameworks, use their public field adapters rather than generic vars serialization.

Benchmark after realistic initialization. CPython's key-sharing dictionaries make ordinary instances cheaper when many share a shape, so constructing one regular instance gives a poor baseline. Include class objects only if comparing many generated classes rather than many instances of one class. Include allocator peaks and resident memory if deployment capacity is the reason for changing the model.

Document subclass policy. A public slotted base that permits arbitrary subclasses may lose its memory guarantee as soon as a consumer omits slots. For a closed hierarchy, assert expected concrete classes lack __dict__ and support weak references where required. Do not rely on inspecting a class's own __slots__ tuple to enumerate inherited storage; descriptors across the MRO or a higher-level schema are the reliable source.

Slots can improve typo detection, but static checking catches more mistakes earlier and without removing dynamism. Treat the runtime restriction as defense in depth. If the class is a broad extension surface, the restriction may cost more than it catches. A measured regular class with clear annotations is often the more maintainable design.

Exercises

  1. Measure ten, ten thousand, and one million regular and slotted instances. State what getsizeof excludes.
  2. Add __dict__ and __weakref__ independently and compare behavior and shallow size.
  3. Build a slotted base, empty-slot mixin, and unslotted leaf. Locate each attribute's storage.
  4. Convert a data class to slots=True and test your project's serializer and weak-reference usage.
  5. Shadow a slot descriptor after class creation, observe the failure, and explain it using descriptor precedence.

Keep this model

Slots are named storage descriptors created with a class. They usually replace the automatic instance dictionary and weak-reference field, but inheritance can restore either capability. Their benefit is reduced fixed overhead for suitable populations; their cost is a narrower object and inheritance contract.

Measure concrete leaf instances, include dictionary storage in comparisons, and separate CPython byte counts from Python semantics. Then decide based on shape and ecosystem compatibility. __slots__ is valuable when the restriction is true of the model, not when it merely makes a one-object benchmark look smaller.

Primary sources