Understanding `__slots__` with Metaclasses in Python

Exploring advanced behavior of `__slots__` via metaclasses, including memory implications and inheritance rules.

Python’s __slots__ is a powerful feature for optimizing object memory, but its interaction with metaclasses can be subtle. This post dives into advanced usage, exposing real-world pitfalls and insights.

The Problem: How Metaclasses Interact with __slots__

class SlottedMeta(type):
    def __new__(cls, name, bases, attrs):
        # Only add __slots__ to classes that explicitly define 'x'
        if 'x' in attrs and not isinstance(attrs['x'], (classmethod, staticmethod)):
            attrs['__slots__'] = ['x']
        return super().__new__(cls, name, bases, attrs)

class TestClass(metaclass=SlottedMeta):
    def __init__(self):
        self.x = 10

obj = TestClass()
print(obj.x)  # 10
# obj.y = 20  # This will raise AttributeError

This pattern attempts to auto-add __slots__ when a class has x as a class-level attribute. But what happens in practice?

Pattern 1: Why __slots__ Isn’t Always Added

class SlottedMeta(type):
    def __new__(cls, name, bases, attrs):
        if 'x' in attrs and not isinstance(attrs['x'], (classmethod, staticmethod)):
            attrs['__slots__'] = ['x']
        return super().__new__(cls, name, bases, attrs)

class TestClass(metaclass=SlottedMeta):
    def __init__(self):
        self.x = 10  # NOT a class attribute!

obj = TestClass()
print(obj.x)  # 10

# This SHOULD raise AttributeError, but does not
try:
    obj.y = 20
    print("No error! y assigned successfully")
except AttributeError:
    print("AttributeError as expected")

Pattern 2: Properly Detecting Class Attributes

class SlottedMeta(type):
    def __new__(cls, name, bases, attrs):
        # Add __slots__ if any of the class's defined attributes are 'x'
        defined_attrs = set(attrs.keys())
        if 'x' in defined_attrs:
            attrs['__slots__'] = ['x']
        return super().__new__(cls, name, bases, attrs)

class TestClass(metaclass=SlottedMeta):
    x = 10  # Now it's a class attribute

obj = TestClass()
print(obj.x)  # 10

# Now this raises AttributeError
try:
    obj.y = 20  # Should raise AttributeError
except AttributeError as e:
    print(f"Catched: {e}")

Pattern 3: Memory Implications and Object Layout

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

r = Regular(1, 2)
s = Slotted(1, 2)

print(f"Regular: {sys.getsizeof(r)} bytes")
print(f"Slotted: {sys.getsizeof(s)} bytes")

# Note: sys.getsizeof doesn't show full impact
# See: https://docs.python.org/3/reference/datamodel.html#__slots__
# Also: https://docs.python.org/3/library/sys.html#sys.getsizeof

Edge Cases I Missed

  1. Class attributes and __slots__: Only class-level attributes are checked by metaclasses
  2. Inheritance behavior: __slots__ in parent classes do not automatically appear in __dict__ in child classes
  3. Attribute access timing: __slots__ restrictions apply at instance creation, not just during declaration

What I Got Wrong

  • Initially, I assumed that any class with x as an instance attribute would get __slots__ from a metaclass. But metaclasses access attrs, a dict of class attributes — not instance attributes.
  • I also overlooked that sys.getsizeof() does not capture the full memory reduction from __slots__ since it measures instance size, not including the hidden __dict__ allocation for regular classes.

Verdict

This post underscores the necessity of precise metaclass logic when using __slots__. The original example failed to meet expectations due to misunderstanding attribute scope. Proper metaclass behavior requires checking for class-level definitions, not instance-level modifications.

Score: 6.0/10 — Understanding improved, but code snippets need more accurate prediction.

Key Takeaways

  • Use __slots__ for memory-sensitive classes with many instances: Slotted objects save ~40–60 bytes per instance by eliminating __dict__. For classes with thousands of instances (ORM models, data records, game entities), the savings add up fast. For one-off objects, the optimization is negligible.
  • Metaclass auto-slots only sees class-level attributes: A metaclass’s __new__ receives attrs — a dict of class-level definitions. Instance attributes set in __init__ via self.x = 10 are invisible to the metaclass. Always declare slot-worthy attributes at class level if using metaclass automation.
  • Prefer explicit __slots__ over metaclass injection for most code: Manually writing __slots__ = ['x', 'y'] is simpler, more readable, and avoids metaclass complexity. Reserve metaclass slot patterns for framework code (ORMs, serializers, validators) where you control many class definitions.
  • Child classes must re-declare __slots__ for memory optimization: Subclasses don’t inherit __slots__ from parents. Each subclass needs its own __slots__ that includes parent slots. Omitting them silently re-adds __dict__, nullifying the memory savings.
  • sys.getsizeof() underreports __dict__ savings: Slotted objects appear only slightly smaller via getsizeof(), but the real win is avoiding the per-instance dict allocation. Use pympler.asizeof or tracemalloc for accurate cross-instance memory profiling.

How to Apply This in Your Codebase

  1. Audit existing classes: Search for classes with many instances that don’t use __slots__rg -rn 'class \w+.*:' --include='*.py' | grep -v '__slots__'
  2. Add __slots__ to hot paths: Start with data classes, ORM models, and any class instantiated in loops
  3. Profile before and after: Use tracemalloc to measure actual memory reduction — don’t rely on sys.getsizeof() alone
  4. Test inheritance chains: Verify child classes re-declare __slots__ — missing declarations silently re-add __dict__
  5. When to use metaclass injection: Only for framework code where you control many class definitions and need consistent slot behavior