Python Slots vs Dict: Performance Comparison

By default, Python stores object attributes in a dictionary accessible via `__dict__`. This provides maximum flexibility—you can add, remove, or modify attributes at runtime. However, this...

Key Insights

  • __slots__ reduces memory usage by 40-50% per instance by replacing the instance dictionary with a fixed-size array, making it essential for applications creating millions of objects
  • Attribute access with __slots__ is 10-20% faster than dictionary-based attributes due to direct memory offset calculations instead of hash table lookups
  • The performance benefits come at the cost of flexibility: __slots__ prevents dynamic attribute assignment and complicates inheritance, making it suitable only for stable, well-defined data structures

Introduction to Python Object Attributes

By default, Python stores object attributes in a dictionary accessible via __dict__. This provides maximum flexibility—you can add, remove, or modify attributes at runtime. However, this flexibility comes with significant memory overhead and performance costs.

The __slots__ mechanism offers an alternative: you declare a fixed set of attributes at class definition time, and Python stores them in a more efficient structure. Here’s the fundamental difference:

# Traditional dict-based class
class PersonDict:
    def __init__(self, name, age, email):
        self.name = name
        self.age = age
        self.email = email

# Slots-based class
class PersonSlots:
    __slots__ = ('name', 'age', 'email')
    
    def __init__(self, name, age, email):
        self.name = name
        self.age = age
        self.email = email

# Usage is identical
p1 = PersonDict("Alice", 30, "alice@example.com")
p2 = PersonSlots("Bob", 25, "bob@example.com")

# But internal storage differs drastically
print(hasattr(p1, '__dict__'))  # True
print(hasattr(p2, '__dict__'))  # False

The dict-based instance has a full dictionary object, while the slots-based instance uses a more compact representation.

How slots Works Under the Hood

When you define __slots__, Python creates descriptors for each attribute name and stores instance data in a fixed-size array rather than a hash table. Each descriptor knows its offset in this array, enabling direct memory access.

Here’s what happens internally:

import sys

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

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

# Create instances
d = WithDict(1, 2, 3)
s = WithSlots(1, 2, 3)

# Memory size comparison
print(f"Dict-based instance: {sys.getsizeof(d)} bytes")
print(f"Dict-based __dict__: {sys.getsizeof(d.__dict__)} bytes")
print(f"Total dict-based: {sys.getsizeof(d) + sys.getsizeof(d.__dict__)} bytes")
print(f"\nSlots-based instance: {sys.getsizeof(s)} bytes")

# Examine the slot descriptors
print(f"\nSlot descriptor for 'x': {type(WithSlots.x)}")

The slots version eliminates the dictionary entirely. Attribute access becomes a simple offset calculation rather than a hash lookup, which explains both the memory savings and speed improvements.

Memory Consumption Benchmarks

The real benefit of __slots__ emerges when creating many instances. Let’s measure the difference with a realistic scenario:

import tracemalloc
import sys

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

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

def measure_memory(point_class, count=1_000_000):
    tracemalloc.start()
    
    points = [point_class(i, i+1, i+2) for i in range(count)]
    
    current, peak = tracemalloc.get_traced_memory()
    tracemalloc.stop()
    
    return current / 1024 / 1024  # Convert to MB

# Measure memory usage
dict_memory = measure_memory(PointDict)
slots_memory = measure_memory(PointSlots)

print(f"Dict-based: {dict_memory:.2f} MB")
print(f"Slots-based: {slots_memory:.2f} MB")
print(f"Savings: {((dict_memory - slots_memory) / dict_memory * 100):.1f}%")

# Per-instance comparison
d = PointDict(1, 2, 3)
s = PointSlots(1, 2, 3)
print(f"\nPer instance - Dict: {sys.getsizeof(d) + sys.getsizeof(d.__dict__)} bytes")
print(f"Per instance - Slots: {sys.getsizeof(s)} bytes")

In typical scenarios, you’ll see 40-50% memory reduction. For a million Point objects, this translates to hundreds of megabytes saved. The savings scale linearly with instance count, making __slots__ crucial for data-intensive applications.

Performance Benchmarks: Attribute Access Speed

Beyond memory, __slots__ provides faster attribute access. Let’s measure the difference:

import timeit

class DataDict:
    def __init__(self, a, b, c, d, e):
        self.a = a
        self.b = b
        self.c = c
        self.d = d
        self.e = e

class DataSlots:
    __slots__ = ('a', 'b', 'c', 'd', 'e')
    
    def __init__(self, a, b, c, d, e):
        self.a = a
        self.b = b
        self.c = c
        self.d = d
        self.e = e

# Setup
dict_obj = DataDict(1, 2, 3, 4, 5)
slots_obj = DataSlots(1, 2, 3, 4, 5)

# Benchmark attribute reads
dict_read = timeit.timeit('obj.a + obj.b + obj.c', 
                          globals={'obj': dict_obj}, 
                          number=10_000_000)
slots_read = timeit.timeit('obj.a + obj.b + obj.c', 
                           globals={'obj': slots_obj}, 
                           number=10_000_000)

print(f"Dict read time: {dict_read:.4f}s")
print(f"Slots read time: {slots_read:.4f}s")
print(f"Speedup: {(dict_read / slots_read - 1) * 100:.1f}%")

# Benchmark attribute writes
dict_write = timeit.timeit('obj.a = 10; obj.b = 20', 
                           globals={'obj': dict_obj}, 
                           number=10_000_000)
slots_write = timeit.timeit('obj.a = 10; obj.b = 20', 
                            globals={'obj': slots_obj}, 
                            number=10_000_000)

print(f"\nDict write time: {dict_write:.4f}s")
print(f"Slots write time: {slots_write:.4f}s")
print(f"Speedup: {(dict_write / slots_write - 1) * 100:.1f}%")

# Benchmark instance creation
dict_create = timeit.timeit('DataDict(1, 2, 3, 4, 5)', 
                            globals={'DataDict': DataDict}, 
                            number=1_000_000)
slots_create = timeit.timeit('DataSlots(1, 2, 3, 4, 5)', 
                             globals={'DataSlots': DataSlots}, 
                             number=1_000_000)

print(f"\nDict creation time: {dict_create:.4f}s")
print(f"Slots creation time: {slots_create:.4f}s")
print(f"Speedup: {(dict_create / slots_create - 1) * 100:.1f}%")

Typical results show 10-20% faster attribute access and 15-25% faster instance creation. While not dramatic for single operations, these gains compound in tight loops or high-throughput systems.

Limitations and Trade-offs

The __slots__ mechanism imposes significant restrictions that make it unsuitable for many use cases:

# Limitation 1: No dynamic attributes
class RestrictedSlots:
    __slots__ = ('x', 'y')
    
    def __init__(self, x, y):
        self.x = x
        self.y = y

obj = RestrictedSlots(1, 2)
# obj.z = 3  # AttributeError: 'RestrictedSlots' object has no attribute 'z'

# Workaround: Include __dict__ in slots (loses some memory benefit)
class FlexibleSlots:
    __slots__ = ('x', 'y', '__dict__')
    
    def __init__(self, x, y):
        self.x = x
        self.y = y

obj2 = FlexibleSlots(1, 2)
obj2.z = 3  # Works now

# Limitation 2: Inheritance complications
class Base:
    __slots__ = ('a',)

class Derived(Base):
    __slots__ = ('b',)  # Must define new slots
    # If you forget __slots__, you get __dict__ back

# Limitation 3: No weak references by default
class NoWeakRef:
    __slots__ = ('x',)

# To enable weak references:
class WithWeakRef:
    __slots__ = ('x', '__weakref__')

# Limitation 4: Multiple inheritance issues
class A:
    __slots__ = ('a',)

class B:
    __slots__ = ('b',)

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

# Workaround: One base must have empty slots
class BEmpty:
    __slots__ = ()

class C(A, BEmpty):  # Works
    __slots__ = ('c',)

These limitations mean __slots__ works best for stable, well-defined data structures where the attribute set is known upfront and won’t change.

Real-World Use Cases and Recommendations

Use __slots__ when:

  1. Creating many instances (thousands to millions) where memory matters
  2. Building data transfer objects with fixed schemas
  3. Implementing performance-critical data structures

Here’s a practical example—a 3D vector class used in a physics simulation:

class Vector3D:
    __slots__ = ('x', 'y', 'z')
    
    def __init__(self, x=0.0, y=0.0, z=0.0):
        self.x = x
        self.y = y
        self.z = z
    
    def magnitude(self):
        return (self.x**2 + self.y**2 + self.z**2) ** 0.5
    
    def __add__(self, other):
        return Vector3D(
            self.x + other.x,
            self.y + other.y,
            self.z + other.z
        )
    
    def __repr__(self):
        return f"Vector3D({self.x}, {self.y}, {self.z})"

# Simulating 100,000 particles
particles = [Vector3D(i, i+1, i+2) for i in range(100_000)]

Stick with dict-based classes when:

  1. Flexibility is needed for dynamic attributes
  2. Working with small instance counts where memory isn’t critical
  3. Building extensible frameworks where users might add attributes
  4. Dealing with complex inheritance hierarchies

Conclusion and Best Practices

The choice between __slots__ and dictionary-based attributes depends on your specific requirements:

Choose __slots__ if:

  • You’re creating 10,000+ instances
  • Memory consumption is a concern
  • Attributes are well-defined and won’t change
  • You need maximum performance for attribute access

Stick with __dict__ if:

  • You need dynamic attribute assignment
  • Instance count is low (< 1,000)
  • Code flexibility and maintainability matter more than performance
  • You’re using complex inheritance or mixins

Don’t prematurely optimize. Profile your application first. If memory or attribute access shows up as a bottleneck and you’re creating many instances of a class, __slots__ is a powerful tool. Otherwise, the flexibility of standard Python objects is usually worth the overhead.

When you do use __slots__, document it clearly—other developers need to know they can’t add arbitrary attributes. Consider providing a factory method or builder pattern if some flexibility is needed while maintaining the performance benefits for the core attributes.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.