DEV Community

MUHAMMAD AHMAD
MUHAMMAD AHMAD

Posted on

Understanding `__slots__` in Python: Memory Optimization and Design Trade-offs

Introduction

Python's dynamic nature makes it flexible, but that flexibility comes with memory overhead. Every object stores its attributes in a dictionary (__dict__), which consumes significant memory especially when creating millions of simple data-holding objects. This article examines __slots__, a Python feature that eliminates this overhead, and explores when it improves code quality versus when it introduces unnecessary complexity.

The Problem: Dictionary Overhead

When you define a simple class in Python:

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

p = Point(1, 2)
print(p.__dict__)  # {'x': 1, 'y': 2}
Enter fullscreen mode Exit fullscreen mode

Each Point instance carries a dictionary. On a 64-bit system, an empty dictionary consumes approximately 72 bytes. For a class with two integers, the dictionary overhead exceeds the data itself. When creating large datasets such as geometric coordinates, CSV records, or graph nodes this overhead accumulates rapidly.

The Solution: __slots__

__slots__ declares fixed attributes for a class, replacing the dynamic dictionary with a fixed-size array:

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

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

p = Point(1, 2)
# p.__dict__  # AttributeError: 'Point' object has no attribute '__dict__'
Enter fullscreen mode Exit fullscreen mode

Memory Impact: Quantified

Testing with 1,000,000 instances on CPython 3.11 (64-bit):

Implementation Bytes per Instance Total Memory
Standard __dict__ ~152 bytes ~152 MB
With __slots__ ~56 bytes ~56 MB
Reduction 63% 96 MB
import sys

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

class SlotPoint:
    __slots__ = ('x', 'y')
    def __init__(self, x, y):
        self.x = x
        self.y = y

print(sys.getsizeof(DictPoint(0, 0)))  # ~48 bytes (object header + dict pointer)
print(sys.getsizeof(SlotPoint(0, 0)))  # ~40 bytes (object header only)
# Full overhead includes the separate dictionary object for DictPoint
Enter fullscreen mode Exit fullscreen mode

How __slots__ Works

Under CPython's object model, instances store attributes in one of two ways:

Standard Objects:

  • Instance holds a pointer to __dict__ (a hash table)
  • Attribute access requires hash computation and table lookup
  • New attributes can be added dynamically at any time

Slotted Objects:

  • Attributes stored in a fixed array at known offsets (like C structs)
  • Access uses array indexing—faster and more cache-friendly
  • Attribute set is fixed at class definition time

Practical Implementation Patterns

1. Basic Data Classes

class Particle:
    __slots__ = ('position', 'velocity', 'mass')

    def __init__(self, position, velocity, mass):
        self.position = position
        self.velocity = velocity
        self.mass = mass
Enter fullscreen mode Exit fullscreen mode

2. Inheritance Considerations

When using inheritance with __slots__, each class must declare its own slots. Empty __slots__ prevents the creation of __dict__ while inheriting parent's slots:

class Entity:
    __slots__ = ('id', 'created_at')

class User(Entity):
    __slots__ = ('email', 'name')  # Has id, created_at, email, name

class Guest(Entity):
    __slots__ = ()  # Has only id, created_at, no __dict__
Enter fullscreen mode Exit fullscreen mode

3. Preserving Dynamic Attributes

If some instances need dynamic attributes, explicitly include __dict__ in slots:

class Flexible:
    __slots__ = ('required_field', '__dict__')

    def __init__(self):
        self.required_field = "fixed"
        # Can still add: self.dynamic = "value"
Enter fullscreen mode Exit fullscreen mode

4. Enabling Weak References

Objects using __slots__ do not support weak references by default. Add __weakref__ to enable this:

class Node:
    __slots__ = ('value', 'children', '__weakref__')
    # Now weakref.ref(instance) works
Enter fullscreen mode Exit fullscreen mode

When to Use __slots__

Appropriate scenarios:

  • High-volume data objects (millions of instances)
  • Simple data containers with fixed schemas
  • Performance-critical attribute access
  • Memory-constrained environments (embedded, mobile, containers)

Inappropriate scenarios:

  • Classes with frequently changing attributes
  • APIs requiring arbitrary attribute assignment by users
  • Classes needing simple pickling without custom methods
  • Code where readability matters more than marginal memory gains

Performance Beyond Memory

__slots__ improves runtime performance through faster attribute access:

import timeit

class DictObj:
    def __init__(self):
        self.x = 0

class SlotObj:
    __slots__ = ('x',)
    def __init__(self):
        self.x = 0

# Attribute access timing
dict_time = timeit.timeit('obj.x', setup='from __main__ import DictObj; obj = DictObj()')
slot_time = timeit.timeit('obj.x', setup='from __main__ import SlotObj; obj = SlotObj()')

print(f"Dict access: {dict_time:.4f}")
print(f"Slot access: {slot_time:.4f}")
# Slot access typically 10-20% faster due to array indexing vs hash lookup
Enter fullscreen mode Exit fullscreen mode

Common Pitfalls

Pitfall 1: Multiple inheritance conflicts

class A:
    __slots__ = ('a',)

class B:
    __slots__ = ('b',)

class C(A, B):
    __slots__ = ('c',)  # Valid

class D(A, B):
    pass  # Also valid—creates __dict__ if not using slots
Enter fullscreen mode Exit fullscreen mode

Pitfall 2: Forgetting __weakref__

class Cached:
    __slots__ = ('data',)

import weakref
c = Cached()
# weakref.ref(c)  # TypeError: cannot create weak reference
Enter fullscreen mode Exit fullscreen mode

Pitfall 3: Pickling complications

Standard pickling works with __slots__, but custom __getstate__ and __setstate__ may be needed for complex scenarios:

class Persistent:
    __slots__ = ('value', 'temp_cache')

    def __getstate__(self):
        return {'value': self.value}  # Exclude temp_cache

    def __setstate__(self, state):
        self.value = state['value']
Enter fullscreen mode Exit fullscreen mode

Design Best Practices

1. Start Simple, Optimize When Measured

Do not default to __slots__. Write standard classes first. Use memory profiling (tracemalloc, pympler) to identify actual bottlenecks before adding complexity.

2. Document Intent Clearly

class Coordinate:
    """
    Memory-optimized coordinate for large-scale geometry processing.

    Uses __slots__ to reduce footprint from ~152 bytes to ~56 bytes
    per instance, critical when handling millions of points.
    """
    __slots__ = ('x', 'y', 'z')
Enter fullscreen mode Exit fullscreen mode

3. Abstract Implementation Details

If switching between slotted and dict-based implementations, use a factory or abstract the decision:

from dataclasses import dataclass
from typing import NamedTuple

# Option 1: dataclass with slots (Python 3.10+)
@dataclass(slots=True)
class Point:
    x: float
    y: float

# Option 2: NamedTuple (immutable, automatically slotted)
class Point(NamedTuple):
    x: float
    y: float
Enter fullscreen mode Exit fullscreen mode

4. Test Both Implementations

When performance matters, maintain test suites verifying both implementations produce identical behavior:

def test_coordinate_behavior(PointClass):
    p = PointClass(1.0, 2.0)
    assert p.x == 1.0
    assert p.y == 2.0
    # Test all expected behavior...

# Run against both implementations
test_coordinate_behavior(DictPoint)
test_coordinate_behavior(SlotPoint)
Enter fullscreen mode Exit fullscreen mode

Alternatives to Consider

Before adopting __slots__, evaluate these alternatives:

Approach Memory Efficiency Mutability Use Case
__slots__ High Yes Many mutable objects
NamedTuple High No Immutable records
@dataclass(slots=True) High Yes Clean syntax, Python 3.10+
array.array Very High Yes Homogeneous numeric data
numpy.ndarray Very High Yes Numerical computing
pandas.DataFrame Very High Yes Tabular data

Conclusion

__slots__ is a specialized tool, not a universal improvement. It trades Python's dynamic flexibility for memory efficiency and attribute access speed. Use it when profiling identifies memory pressure from object overhead, particularly in data-intensive applications. Avoid it when code clarity, flexibility, or rapid prototyping takes priority.

The most robust approach: write clear, correct code first, measure resource usage, then apply targeted optimizations where data proves they matter.

Top comments (0)