__slots__ - Dramatic memory reduction
What is __slots__?
Section titled “What is __slots__?”By default, every Python instance stores its attributes in a __dict__, a regular Python dictionary that lives on the heap
alongside the object. This dictionary is flexible and allows you to add any attribute at any time, but it comes with a significant
hidden memory cost, a dictionary has substantial overhead even when it only holds a few keys.
__slots__ is a class-level declaration that tells Python exactly which attributes an instance will ever have.
Instead of creating a __dict__ for each instance, Python allocates a fixed, compact block of memory with one slot per
attribute, like a C struct rather than a dictionary.
The Hidden Cost of __dict__
Section titled “The Hidden Cost of __dict__”When Python creates an instance, it actually allocates two separate objects on the heap, not one:
-
Object 1 — the instance itself This is the actual object, it holds the type information, the reference count, and a pointer to the
__dict__. It does not hold the attribute values directly. -
Object 2 — the
__dict__This is a separate, standalone Python dictionary that lives next to the instance on the heap. This is where the actual attribute values are stored.
rp = RegularPoint(1.0, 2.0, 3.0)
Heap ────
Object 1 Object 2 ──────── ──────── [ RegularPoint instance ] [ __dict__ ] type: RegularPoint ─────────────────────────────────► "x" → 1.0 refcount: 1 ▲ "y" → 2.0 pointer to __dict__ ──────┘ "z" → 3.0
~48 bytes ~232 bytes (the instance) (the dictionary)You can verify both objects exist independently:
rp = RegularPoint(1.0, 2.0, 3.0)
# Object 1 — the instanceprint(sys.getsizeof(rp)) # ~48 bytesprint(type(rp)) # <class 'RegularPoint'>print(id(rp)) # memory address of Object 1
# Object 2 — the __dict__print(sys.getsizeof(rp.__dict__)) # ~232 bytesprint(type(rp.__dict__)) # <class 'dict'>print(id(rp.__dict__)) # different memory address — Object 2So when you access rp.x, Python has to do two steps:
rp.x
Step 1 — find the instance on the heap ────── rp ──────────────────────────► [ RegularPoint instance ] pointer to __dict__ ──┐ │ Step 2 — follow the pointer to __dict__, look up "x" │ ────── │ ◄───────────────────────┘ [ __dict__ ] "x" → 1.0 ← found it "y" → 2.0 "z" → 3.0With __slots__ there is only one object, the instance itself holds the attribute values directly, no dictionary,
no second object, no two-step lookup:
sp = SlottedPoint(1.0, 2.0, 3.0)
Heap ────
Object 1 only ───────────── [ SlottedPoint instance ] type: SlottedPoint refcount: 1 slot x → 1.0 ← stored directly in the instance slot y → 2.0 ← no pointer, no second object slot z → 3.0
~72 bytes total — one object, one allocationSo the “two memory costs” means literally two heap allocations: one for the instance object and one for its
__dict__ dictionary, where __slots__ reduces this to a single allocation.
Closer comparison
Section titled “Closer comparison” rp = RegularPoint(1.0, 2.0, 3.0)
Heap ──── [ RegularPoint instance ] ~48 bytes │ └──► [ __dict__ ] ~232 bytes ← the hidden cost │ ├── "x" ──► [ float: 1.0 ] ├── "y" ──► [ float: 2.0 ] └── "z" ──► [ float: 3.0 ]
total per instance: ~280 bytesWith __slots__, the dictionary disappears entirely:
sp = SlottedPoint(1.0, 2.0, 3.0)
Heap ──── [ SlottedPoint instance ] ~72 bytes │ ├── slot x ──► [ float: 1.0 ] ├── slot y ──► [ float: 2.0 ] └── slot z ──► [ float: 3.0 ]
total per instance: ~72 bytes no __dict__ — slots are part of the object itselfHow to use __slots__
Section titled “How to use __slots__”class RegularPoint: def __init__(self, x, y, z): self.x = x self.y = y self.z = z # Python automatically creates __dict__ for each instance
class SlottedPoint: __slots__ = ["x", "y", "z"] # pre-declare all attributes
def __init__(self, x, y, z): self.x = x self.y = y self.z = z # no __dict__ created — attributes stored in fixed slotsReal World Impact at Scale
Section titled “Real World Impact at Scale”The difference per instance is modest, but across hundreds of thousands of instances it becomes significant:
import tracemalloc
tracemalloc.start()
# 100,000 regular instancesregular = [RegularPoint(i, i, i) for i in range(100_000)]snapshot1 = tracemalloc.take_snapshot()regular_stats = snapshot1.statistics("lineno")regular_mem = sum(stat.size for stat in regular_stats)
# 100,000 slotted instancesslotted = [SlottedPoint(i, i, i) for i in range(100_000)]snapshot2 = tracemalloc.take_snapshot()slotted_stats = snapshot2.statistics("lineno")slotted_mem = sum(stat.size for stat in slotted_stats)
print(f"regular: {regular_mem / 1024 / 1024:.1f} MB")print(f"slotted: {slotted_mem / 1024 / 1024:.1f} MB") 100,000 instances
RegularPoint SlottedPoint ──────────── ────────────
100,000 × ~280 bytes 100,000 × ~72 bytes = ~28 MB = ~7 MB
~75% less memory at scaleWhen to Use __slots__
Section titled “When to Use __slots__”| Regular class | __slots__ class | |
|---|---|---|
| Memory per instance | ~280 bytes | ~72 bytes |
| Dynamic attributes | ✅ Yes | ❌ No |
__dict__ present | ✅ Yes | ❌ No |
| Best for | General purpose objects | Large collections of simple objects |
| Typical use case | Business logic, models | Points, vectors, records, data classes |