Day 5 · ~18m

__slots__, __dict__, and How Python Stores Your Data in Memory

Every Python instance carries a hidden __dict__ dictionary. For 100k Order objects, that overhead adds up. Learn __slots__ and when the tradeoff is worth it.

student (thinking)

After len and getitem on Day 3, and descriptor protocol internals yesterday — I'm starting to see that Python has a lot of machinery running underneath things I thought I understood. What else am I missing?

teacher (neutral)

Every instance of every class you have ever written has a dictionary attached to it. You never made that dictionary. Python made it for you. It is called __dict__, and it is how Python stores your instance attributes.

student (surprised)

Wait — when I do self.order_id = order_id in __init__, that isn't storing data in the object directly. It is putting a key-value pair into a hidden dictionary?

teacher (serious)

Exactly. Try it yourself: create any instance and print its __dict__. You will see every attribute you assigned in __init__ sitting there as a plain Python dictionary.

class Order:
    def __init__(self, id, customer, total, status):
        self.id = id
        self.customer = customer
        self.total = total
        self.status = status

o = Order('ORD-001', 'Alice Chen', 99.99, 'paid')
print(o.__dict__)
# {'id': 'ORD-001', 'customer': 'Alice Chen', 'total': 99.99, 'status': 'paid'}
student (focused)

So __dict__ is literally there. And because it's a dict, you can add arbitrary attributes to any instance at runtime — even ones the class never defined.

teacher (neutral)

You can do exactly that. o.something_new = 42 works on any regular instance. Python writes it into __dict__. This is intentional — Python's dynamic nature runs through __dict__. The descriptor protocol we covered yesterday uses __dict__ lookups as part of the attribute access chain.

student (curious)

Okay, but I'm thinking about our order pipeline. At peak we're holding around 100,000 Order objects in memory at once. Every single one of them has its own dictionary?

teacher (serious)

Every single one. And a Python dictionary is not cheap. An empty dict uses around 232 bytes on CPython 3.12. A dict with four entries uses around 360 bytes. That is just the dict — not the strings and numbers stored in it, which have their own overhead on top.

student (focused)

So 100,000 Order instances, each with a 360-byte dict just for the four attributes — that's 36 megabytes of dictionary overhead before we even count the strings and floats.

teacher (encouraging)

You did that math immediately. That is the right instinct. And you can measure it:

import sys
import tracemalloc

class Order:
    def __init__(self, id, customer, total, status):
        self.id = id
        self.customer = customer
        self.total = total
        self.status = status

tracemalloc.start()
orders = [Order(f'ORD-{i}', 'Alice Chen', 99.99, 'paid') for i in range(100_000)]
current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
print(f'Peak memory: {peak / 1024 / 1024:.1f} MB')
student (curious)

What's the alternative? You can't just get rid of the dict, can you?

teacher (neutral)

You can — by telling Python exactly which attributes the class will ever have. That is what __slots__ does.

class SlottedOrder:
    __slots__ = ('id', 'customer', 'total', 'status')

    def __init__(self, id, customer, total, status):
        self.id = id
        self.customer = customer
        self.total = total
        self.status = status

When you declare __slots__, Python does not create a __dict__ for each instance. Instead, it allocates a fixed block of memory with exactly the slots you listed. The object has no warehouse — it has only the rooms it was built with.

student (thinking)

So it's like the difference between building an office with an empty warehouse attached — "in case we need to store something later" — versus building exactly the rooms you know you need. The warehouse takes space even if it's empty.

teacher (amused)

That is the analogy I was going to reach for. Yes. A regular class comes with an empty warehouse on every instance. __slots__ says "we know the floor plan, build only these rooms." No warehouse. Fixed allocation.

student (surprised)

And this actually changes memory usage measurably — not just in theory?

teacher (excited)

Run it side by side:

import sys
import tracemalloc

class Order:
    def __init__(self, id, customer, total, status):
        self.id = id
        self.customer = customer
        self.total = total
        self.status = status

class SlottedOrder:
    __slots__ = ('id', 'customer', 'total', 'status')

    def __init__(self, id, customer, total, status):
        self.id = id
        self.customer = customer
        self.total = total
        self.status = status

# Measure a single instance size
regular = Order('ORD-001', 'Alice Chen', 99.99, 'paid')
slotted = SlottedOrder('ORD-001', 'Alice Chen', 99.99, 'paid')

print(f'Regular instance:  {sys.getsizeof(regular)} bytes')
print(f'Slotted instance:  {sys.getsizeof(slotted)} bytes')
print(f'Regular __dict__:  {sys.getsizeof(regular.__dict__)} bytes')
# Regular instance:  48 bytes
# Slotted instance:  56 bytes        <- base object is similar
# Regular __dict__:  232 bytes       <- THIS is what you're eliminating

The slotted instance base is similar to the regular one — but the regular one also carries a 232-byte dict (minimum, before any entries). At 100,000 instances, eliminating that dict saves roughly 22 MB of dict objects alone.

student (focused)

So the savings aren't in the instance itself — they're in removing the attached dict from every single instance.

teacher (neutral)

Correct. And __slots__ also changes what you can do. No __dict__ means no dynamic attribute assignment:

slotted = SlottedOrder('ORD-001', 'Alice Chen', 99.99, 'paid')
slotted.extra = 'something'  # AttributeError: 'SlottedOrder' has no attribute 'extra'
student (thinking)

That is actually a feature, not a bug. If Order objects should only ever have these four fields, making it raise an error if someone tries to add a fifth one is... good? It's defensive.

teacher (encouraging)

That is the mature take. __slots__ is both a memory optimization and a structural constraint. You are trading flexibility for a guarantee: instances of this class will only ever have these attributes. In a data pipeline where you are creating hundreds of thousands of the same object, you almost certainly know the shape upfront.

student (curious)

What are the tradeoffs? You said on Day 4 that descriptor protocol and @property work through attribute access — does __slots__ break any of that?

teacher (serious)

__slots__ is itself implemented using descriptors. Each slot becomes a data descriptor on the class — which is why accessing order.id still works through the normal attribute lookup chain. @property works fine on slotted classes.

The real tradeoff is multiple inheritance. If you try to combine two classes that both define __slots__, Python will refuse or silently create a __dict__ anyway. The rule is: every class in a multiple-inheritance chain must either all use __slots__ or none of them should. Mixing is a footgun.

class A:
    __slots__ = ('x',)

class B:
    __slots__ = ('y',)

class C(A, B):  # Works — both parents use __slots__
    __slots__ = ('z',)

class D:        # No __slots__
    pass

class E(A, D):  # D has __dict__, so E gets __dict__ too — slots win nothing
    __slots__ = ('w',)
student (focused)

So for our Order class — a simple data-carrying object with no inheritance — this is a clean win. For something in a complex class hierarchy, I would want to be careful.

teacher (neutral)

That is the practical judgment. Flat data classes that you create in bulk: strong candidate. Deep inheritance hierarchies: measure first, and know the rules. For exactly what your pipeline does — processing 100,000 Order objects in a tight loop — __slots__ is one of the few Python optimizations that is both significant and low-risk.

student (excited)

And because I know the shape of an Order — four fields, nothing dynamic — I'm not giving up anything. The warehouse was always empty. I was just paying rent on it.

teacher (amused)

Paying rent on 100,000 empty warehouses simultaneously. Yes. That is the situation.

student (proud)

Day 5 and I'm already finding 22 megabytes of free memory. I'm going to put a comment in the PR that says exactly that.

teacher (focused)

Put the benchmark in the PR, not just the number. Anyone can claim 22 MB. A tracemalloc comparison before and after is evidence. Senior engineers respect measurements, not assertions.

student (thinking)

Okay — one thing I'm unclear on. __slots__ lives at the class level, not the instance level. So Python is using the class definition to change how instances are built? The class itself is controlling memory layout at instance creation time?

teacher (serious)

You just asked the question that leads directly to tomorrow. If the class controls how instances are built — who controls how the class is built? How does class Order: become a class object in the first place? What is Python actually doing when it executes a class body?

student (curious)

You're about to tell me there's more machinery I don't know about, aren't you.

teacher (amused)

type(). You have probably used it to check what type something is. It is also the function Python calls to build every class you have ever written. Tomorrow we find out what that actually means.