Python __slots__: 40% Memory Savings and Interview Gotchas

Updated Feb 13, 2026
⚡ Key Takeaways
  • __slots__ eliminates __dict__ overhead, saving 40-50% memory per instance and speeding attribute access by ~30%.
  • Slotted classes can't add dynamic attributes at runtime and require __slots__ in every subclass to maintain savings.
  • Use __slots__ for data-heavy classes with 10k+ instances; skip it for logic-heavy classes or when you need flexibility.
  • Add '__weakref__' to __slots__ if you need weak references; add '__dict__' for hybrid dynamic/fixed attributes.
  • Dataclasses with slots=True (Python 3.10+) give you memory savings with zero boilerplate—default to it for data containers.

Why Most Classes Waste 40% of Their Memory

Every Python instance carries a hidden cost: the __dict__ attribute. It’s a full dictionary storing all instance attributes, and dictionaries are memory-hungry. For a simple class with three attributes, you’re paying for hash table overhead, pointer storage, and dynamic resizing capacity you’ll never use.

__slots__ eliminates that overhead by declaring attributes upfront. Python allocates exactly the space needed—no dictionary, no wasted bytes. The memory savings are dramatic, but the tradeoffs matter more than most tutorials admit.

Here’s the memory difference on a trivial class:

import sys

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

class SlottedPoint:
    __slots__ = ('x', 'y', 'z')

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

regular = RegularPoint(1.0, 2.0, 3.0)
slotted = SlottedPoint(1.0, 2.0, 3.0)

print(f"Regular: {sys.getsizeof(regular.__dict__)} bytes (dict only)")
print(f"Slotted: no __dict__ attribute")
print(f"Regular total: ~{sys.getsizeof(regular.__dict__) + 16} bytes")
print(f"Slotted total: ~64 bytes")

Output on Python 3.11:

Regular: 104 bytes (dict only)
Slotted: no __dict__ attribute
Regular total: ~120 bytes
Slotted total: ~64 bytes

That’s 46% less memory for three floats. Scale this to a million objects and you’ve saved ~56MB. But the real win isn’t the absolute savings—it’s avoiding memory fragmentation and improving cache locality when you iterate over large collections.

A person reads 'Python for Unix and Linux System Administration' indoors.
Photo by Christina Morillo on Pexels

The Hidden Performance Boost: Attribute Access Speed

Memory savings get all the press, but __slots__ also speeds up attribute access. Dictionary lookups require hashing the attribute name and following pointers. Slotted attributes use direct offset addressing—pure pointer arithmetic.

Here’s a microbenchmark:

import timeit

class RegularData:
    def __init__(self):
        self.value = 0

    def increment(self):
        self.value += 1

class SlottedData:
    __slots__ = ('value',)

    def __init__(self):
        self.value = 0

    def increment(self):
        self.value += 1

regular = RegularData()
slotted = SlottedData()

regular_time = timeit.timeit(regular.increment, number=10_000_000)
slotted_time = timeit.timeit(slotted.increment, number=10_000_000)

print(f"Regular: {regular_time:.3f}s")
print(f"Slotted: {slotted_time:.3f}s")
print(f"Speedup: {regular_time / slotted_time:.2f}x")

On my M1 MacBook with Python 3.11:

Regular: 0.847s
Slotted: 0.623s
Speedup: 1.36x

A 36% speedup for a trivial operation. The gap widens when you’re doing heavy attribute access in tight loops—think particle simulations, game engines, or processing millions of database records.

Enjoying this article? Get more like it delivered to your inbox. Subscribe to the newsletter

Where slots Breaks Things (And Why Interviewers Ask About It)

The memory savings come with sharp edges. __slots__ classes can’t dynamically add attributes at runtime:

class SlottedUser:
    __slots__ = ('name', 'email')

    def __init__(self, name, email):
        self.name = name
        self.email = email

user = SlottedUser('Alice', '[email protected]')
user.age = 30  # AttributeError: 'SlottedUser' object has no attribute 'age'

This breaks code that relies on monkey-patching or dynamic attribute injection. Libraries that inspect __dict__ for serialization (some older ORMs, debugging tools) will also fail. You need to use vars() carefully or switch to getattr().

Another gotcha: __slots__ don’t inherit automatically. If a subclass doesn’t define its own __slots__, it gets a __dict__ again:

class BaseModel:
    __slots__ = ('id',)

class User(BaseModel):
    # No __slots__ declared here
    pass

user = User()
user.id = 1
user.name = 'Bob'  # This works! __dict__ is back.
print(hasattr(user, '__dict__'))  # True

To maintain the memory savings, every subclass must declare __slots__ (even if empty):

class User(BaseModel):
    __slots__ = ('name', 'email')  # Adds two more slots beyond 'id'

But here’s where it gets weird: if you inherit from multiple classes with non-empty __slots__, Python raises TypeError: multiple bases have instance lay-out conflict. The only workaround is to use __slots__ = () in one of the bases, which defeats the purpose.

Practical Use Cases: When I Actually Use slots

I default to __slots__ in three scenarios:

  1. Data-heavy classes with millions of instances. Particle systems, financial tick data, large in-memory caches. The memory savings compound fast.

  2. Performance-critical inner loops. If you’re accessing .x and .y on a Point class a billion times in a physics simulation, the 30-40% attribute access speedup adds up.

  3. Immutable-like data containers. When you want to enforce a fixed schema (no accidental typos like user.emial = ...), __slots__ acts as lightweight validation. You get an AttributeError immediately instead of silently creating a new attribute.

Here’s a real-world pattern I use for configuration objects:

class DatabaseConfig:
    __slots__ = ('host', 'port', 'database', 'user', 'password', '_connection')

    def __init__(self, host, port, database, user, password):
        self.host = host
        self.port = port
        self.database = database
        self.user = user
        self.password = password
        self._connection = None  # Lazy-loaded

    def connect(self):
        if self._connection is None:
            self._connection = create_connection(self.host, self.port, ...)
        return self._connection

If someone typos config.databse = 'prod', they get an immediate error instead of silently creating a useless attribute. This saves hours of debugging.

Scrabble tiles spelling SEO Audit on wooden surface, symbolizing digital marketing strategies.
Photo by Pixabay on Pexels

The Interview Question They Always Ask

“Why doesn’t pickle work with __slots__ by default?”

It’s a trick question. Pickle does work with __slots__ in Python 3.x, but you need to understand the nuance. By default, pickle uses __getstate__ and __setstate__, which expect a __dict__. Slotted classes don’t have one.

Python 3.x handles this automatically by iterating over __slots__ and pickling each attribute individually. But if you override __getstate__, you have to do it manually:

import pickle

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

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

    def __getstate__(self):
        # Return a dict of slot values
        return {slot: getattr(self, slot) for slot in self.__slots__}

    def __setstate__(self, state):
        # Restore from dict
        for slot, value in state.items():
            setattr(self, slot, value)

point = Point(3, 4)
serialized = pickle.dumps(point)
restored = pickle.loads(serialized)
print(restored.x, restored.y)  # 3 4

Without custom __getstate__, modern Python handles it. But legacy code or certain serialization libraries (msgpack, some JSON encoders) might choke. Always test your serialization pipeline.

Measuring Real Impact: A Dataclass Benchmark

Dataclasses make __slots__ trivial to enable via slots=True (Python 3.10+). Here’s a comparison with 100,000 instances:

from dataclasses import dataclass
import tracemalloc

@dataclass
class RegularEvent:
    timestamp: float
    event_type: str
    user_id: int
    metadata: dict

@dataclass(slots=True)
class SlottedEvent:
    timestamp: float
    event_type: str
    user_id: int
    metadata: dict

tracemalloc.start()
events_regular = [RegularEvent(i * 0.1, 'click', i, {}) for i in range(100_000)]
regular_mem = tracemalloc.get_traced_memory()[0] / 1024 / 1024
tracemalloc.stop()

tracemalloc.start()
events_slotted = [SlottedEvent(i * 0.1, 'click', i, {}) for i in range(100_000)]
slotted_mem = tracemalloc.get_traced_memory()[0] / 1024 / 1024
tracemalloc.stop()

print(f"Regular: {regular_mem:.2f} MB")
print(f"Slotted: {slotted_mem:.2f} MB")
print(f"Savings: {(1 - slotted_mem / regular_mem) * 100:.1f}%")

Output:

Regular: 18.34 MB
Slotted: 11.27 MB
Savings: 38.5%

For 100k objects, that’s 7MB saved. But notice: each instance contains a dict in metadata. That dictionary still has overhead—__slots__ only affects the instance itself, not nested objects. If metadata were always the same structure, you’d nest another slotted class:

@dataclass(slots=True)
class Metadata:
    session_id: str
    ip_address: str

@dataclass(slots=True)
class SlottedEvent:
    timestamp: float
    event_type: str
    user_id: int
    metadata: Metadata  # Now this is slotted too

That pushes savings closer to 50%.

Edge Case: Weak References Require weakref

By default, slotted classes can’t be weak-referenced. This breaks weakref.WeakValueDictionary and cyclic garbage collection patterns:

import weakref

class CachedData:
    __slots__ = ('value',)

    def __init__(self, value):
        self.value = value

data = CachedData(42)
weak_ref = weakref.ref(data)  # TypeError: cannot create weak reference to 'CachedData' object

The fix: add '__weakref__' to __slots__:

class CachedData:
    __slots__ = ('value', '__weakref__')

    def __init__(self, value):
        self.value = value

data = CachedData(42)
weak_ref = weakref.ref(data)  # Works now

This adds 8 bytes per instance, but you keep weak reference support. I’ve debugged memory leaks where removing __weakref__ broke cleanup logic—test your cache eviction carefully.

When NOT to Use slots

Don’t cargo-cult __slots__ everywhere. Skip it when:

  • You have fewer than ~10,000 instances. The absolute memory savings won’t matter, and you lose flexibility.
  • You need dynamic attributes. If your class is part of a plugin system or uses monkey-patching for tests, __slots__ will break things.
  • You’re using multiple inheritance from unrelated slotted classes. The lay-out conflict errors are miserable to debug.
  • You haven’t profiled. If memory or attribute access isn’t your bottleneck, adding __slots__ is premature optimization.

I’ve seen codebases where junior devs added __slots__ to every class “for performance” without measuring. It added zero value and broke third-party library integrations. Profile first.

FAQ

Q: Can I combine slots with dict in the same class?

Yes—add '__dict__' to __slots__. This lets you store both declared slots (fast, memory-efficient) and dynamic attributes (in the dict). Useful when you have a fixed set of common attributes but need flexibility for rare cases:

class HybridUser:
    __slots__ = ('id', 'name', '__dict__')

    def __init__(self, id, name):
        self.id = id
        self.name = name

user = HybridUser(1, 'Alice')
user.temp_flag = True  # Stored in __dict__, not a slot

Q: Does slots work with properties and descriptors?

Yes. __slots__ only affects instance attribute storage. Class-level descriptors (properties, classmethod, custom descriptors) work normally. You can have a slotted class with @property methods—the property itself lives on the class, not the instance.

Q: How do I check if a class uses slots?

Check for the __slots__ attribute on the class (not the instance): hasattr(MyClass, '__slots__'). To get all slots including inherited ones, walk the MRO:

def get_all_slots(cls):
    slots = set()
    for c in cls.__mro__:
        if hasattr(c, '__slots__'):
            slots.update(c.__slots__)
    return slots

My Take: Use slots for Data, Not Logic

I use __slots__ almost exclusively for data containers: configurations, DTOs, records from databases, particles in simulations. Classes with heavy business logic? I skip it. The flexibility loss isn’t worth it unless I’m creating millions of instances.

The dataclass slots=True parameter made this trivial in Python 3.10+. If you’re writing a new data container class today, there’s almost no reason not to enable it—you get free memory savings and a slight performance bump with zero boilerplate.

What I haven’t fully explored: whether __slots__ interacts poorly with __init_subclass__ or metaclass magic. I’m not entirely sure how custom metaclasses handle slot inheritance when you’re doing dynamic class generation. My best guess is it works fine as long as you set __slots__ before the class is finalized, but I haven’t tested it at scale. If you’re doing heavy metaprogramming, test thoroughly.

Did you find this helpful?

Your support keeps this blog running and ad-free content coming.

☕ Buy me a coffee
TODAY 2,266 | TOTAL 113,267