- __dict__ vs __slots__: Deep Dive & Architectural Comparison
- Under the Hood: CPython Struct Offsets, Descriptors & Dynamic Access
- Slot Lifecycle: Uninitialized Attributes & Empty Slots ()
- Dynamic State & Metaprogramming: Working with __dict__ and vars()
- Memory Optimization: Measuring Real-World Footprint
- Modern Patterns: Dataclasses(slots=True), Weakrefs & Descriptors
- Inheritance Pitfalls, Layout Conflicts & Edge Cases
__dict__ vs __slots__: Deep Dive & Architectural Comparison
By default, Python objects store their instance attributes inside a dynamic hash map accessible via __dict__. While this provides maximum dynamism, it introduces significant memory overhead and indirection. The __slots__ declaration replaces this dictionary with fixed memory offsets at the C level.
| Feature | Default Behavior (__dict__) |
Optimized Layout (__slots__) |
|---|---|---|
| Storage Mechanism | Dynamic PyDictObject per instance |
Fixed C-level array of pointer offsets (Descriptors) |
| Memory Overhead (per instance) | High (~150–200 bytes base overhead) | Minimal (~48–56 bytes base overhead) |
| Attribute Access Speed | Standard hash map lookup | ~15–25% faster via direct memory offset |
Dynamic Access via getattr() / setattr() |
Supported for any arbitrary key name | Supported strictly for keys declared in __slots__ |
| Dynamic Attribute Assignment | Arbitrary attributes allowed at runtime | Restricted strictly to predefined names |
Direct Dictionary Access (obj.__dict__) |
Supported (obj.__dict__['x']) |
❌ Raises AttributeError (No __dict__) |
| Weak Reference Support | Enabled by default | Requires explicit '__weakref__' in slots |
Introspection via vars() |
Supported directly | ❌ Raises TypeError (no __dict__) |
– Attribute assignment restrictions:
Declaring __slots__ creates an immutable attribute whitelist on the class layout:
class StandardPoint: def __init__(self, x: float, y: float) -> None: self.x = x self.y = y class SlottedPoint: __slots__ = ("x", "y") def __init__(self, x: float, y: float) -> None: self.x = x self.y = y # 1. Standard instance allows dynamic attribute injection p1 = StandardPoint(1.0, 2.0) p1.z = 3.0 # Successfully added to p1.__dict__ # 2. Slotted instance prevents undeclared attribute assignment p2 = SlottedPoint(1.0, 2.0) try: p2.z = 3.0 except AttributeError as e: print(e) # AttributeError: 'SlottedPoint' object has no attribute 'z' |
Under the Hood: CPython Struct Offsets, Descriptors & Dynamic Access
– What declaring __slots__ = ("x", "y") actually produces:
1. CPython creates an internal C struct containing fixed pointer locations for x and y instead of allocating a PyDictObject.
2. Two member_descriptor objects are added to the class dictionary (SlottedVector.__dict__['x'] and SlottedVector.__dict__['y']).
– Dynamic attribute retrieval: getattr() vs __dict__:
You can still access slotted attributes dynamically at runtime using getattr() and setattr(), because Python routes these functions through the class descriptors. However, direct dictionary subscription is impossible since __dict__ does not exist.
class SlottedVector: __slots__ = ("x", "y") def __init__(self, x: float, y: float) -> None: self.x = x self.y = y vec = SlottedVector(10.0, 20.0) # 1. Inspecting class-level member descriptors print(type(SlottedVector.x)) # <class 'member_descriptor'> print(SlottedVector.x) # <member 'x' of 'SlottedVector' objects> # 2. Dynamic runtime access via getattr/setattr (Fully Functional): attr_name = "x" print(getattr(vec, attr_name)) # 10.0 setattr(vec, "y", 99.0) print(vec.y) # 99.0 # 3. Dynamic assignment of UNDECLARED fields fails: try: setattr(vec, "z", 30.0) except AttributeError as e: print(e) # AttributeError: 'SlottedVector' object has no attribute 'z' # 4. Direct dict access fails (no internal dictionary): try: print(vec.__dict__["x"]) except AttributeError as e: print(e) # AttributeError: 'SlottedVector' object has no attribute '__dict__' |
Slot Lifecycle: Uninitialized Attributes & Empty Slots ()
– 1. Uninitialized slots behavior (NULL pointer state):
Declaring a slot reserves memory space in C but does not assign a default value. Reading a declared slot before explicit assignment raises an AttributeError.
class PartialNode: __slots__ = ("id", "payload") def __init__(self, node_id: int) -> None: self.id = node_id # Note: 'payload' is declared in slots but not assigned here node = PartialNode(1) print(node.id) # 1 # Accessing unassigned slot raises AttributeError: try: print(node.payload) except AttributeError as e: print(e) # AttributeError: 'PartialNode' object has no attribute 'payload' print(hasattr(node, "payload")) # False (until assigned) node.payload = {"data": 42} # Assigning initializes the slot print(node.payload) # {'data': 42} |
– 2. Completely empty slots: __slots__ = ():
If a class specifies an empty tuple __slots__ = (), it creates instances with zero instance-level attributes and no __dict__. This is widely used for stateless utilities and base mixin classes.
class ImmutableUtility: __slots__ = () # No attributes allowed, no __dict__ created def execute(self) -> str: return "Action performed" util = ImmutableUtility() # Any attribute assignment will be strictly rejected: try: util.temp = "test" except AttributeError as e: print(e) # AttributeError: 'ImmutableUtility' object has no attribute 'temp' |
Dynamic State & Metaprogramming: Working with __dict__ and vars()
– Metaprogramming and serialization workflows:
Standard classes expose their state via __dict__, making introspection, generic serialization, and runtime monkey-patching straightforward.
– Dynamic namespace inspection:
import types class DynamicEntity: def __init__(self, name: str, role: str) -> None: self.name = name self.role = role entity = DynamicEntity("Alice", "Admin") # 1. Reading raw state mapping print(entity.__dict__) # {'name': 'Alice', 'role': 'Admin'} print(vars(entity)) # Equivalent to entity.__dict__ # 2. Dynamic state mutation entity.__dict__["status"] = "Active" print(entity.status) # "Active" # 3. Dynamic execution into a module dictionary dynamic_module = types.ModuleType("runtime_mod") exec("def compute(x): return x * 2", dynamic_module.__dict__) print(dynamic_module.compute(21)) # 42 |
Memory Optimization: Measuring Real-World Footprint
– Real memory allocation discrepancy:
sys.getsizeof() only measures the shallow memory of an instance pointer. For standard instances, it omits the separate memory block allocated for the inner __dict__ table.
import sys class DefaultNode: def __init__(self, val: int) -> None: self.val = val class SlottedNode: __slots__ = ("val",) def __init__(self, val: int) -> None: self.val = val node_dict = DefaultNode(42) node_slot = SlottedNode(42) # Shallow size comparison: print(sys.getsizeof(node_slot)) # ~48 bytes print(sys.getsizeof(node_dict)) # ~48 bytes (shallow object header only) # True size including the underlying dictionary: dict_overhead = sys.getsizeof(node_dict.__dict__) print(sys.getsizeof(node_dict) + dict_overhead) # ~152 bytes (3x larger) |
Modern Patterns: Dataclasses(slots=True), Weakrefs & Descriptors
– Python 3.10+ native slot generation:
Modern codebases avoid manual tuple definitions by leveraging the slots=True parameter in the standard dataclass decorator.
– Combining slots with optional features:
import weakref from dataclasses import dataclass # 1. Modern Dataclass with automated slots @dataclass(slots=True) class Coordinate: latitude: float longitude: float # 2. Enabling Weak References alongside __slots__ class ObservableTask: __slots__ = ("task_id", "__weakref__") def __init__(self, task_id: str) -> None: self.task_id = task_id task = ObservableTask("TASK-101") ref = weakref.ref(task) print(ref().task_id) # "TASK-101" # 3. Restoring dynamic attributes selectively by adding '__dict__' to slots class HybridEntity: __slots__ = ("fixed_id", "__dict__") def __init__(self, fixed_id: int) -> None: self.fixed_id = fixed_id hybrid = HybridEntity(1) hybrid.arbitrary_tag = "custom" # Works: dynamic fields stored in hybrid.__dict__ |
Inheritance Pitfalls, Layout Conflicts & Edge Cases
– 1. Implicit __dict__ reintroduction in subclasses:
If a parent class defines __slots__ but a derived subclass omits __slots__, the subclass automatically gains a __dict__, negating memory optimizations for derived instances.
class BaseSlot: __slots__ = ("a",) class BrokenChild(BaseSlot): pass # No __slots__ defined: __dict__ is created! child = BrokenChild() print(hasattr(child, "__dict__")) # True |
– 2. Multiple inheritance layout conflicts:
CPython prohibits inheriting from multiple parent classes that both define non-empty __slots__:
class PositionBase: __slots__ = ("x", "y") class ColorBase: __slots__ = ("color",) # Multiple inheritance with distinct non-empty slots fails: try: class RenderedPoint(PositionBase, ColorBase): pass except TypeError as e: print(e) # TypeError: multiple bases have instance lay-out conflict |
– 3. Safe mixin architecture:
To design mixins compatible with slotted classes, declare an empty slots tuple (__slots__ = ()) on abstract parents and mixin classes.
class LoggerMixin: __slots__ = () # Prevents __dict__ creation without causing struct layout conflicts def log(self, message: str) -> None: print(f"[LOG]: {message}") class SecureEndpoint(BaseSlot, LoggerMixin): __slots__ = ("token",) |