💡 Python QuickBits — 📦 Memory Saver with __slots__


Description:

When you create many instances of a class in Python, each one carries an internal dict to store attributes. This makes objects flexible — but also memory-heavy.

Python offers an optimization: slots. By declaring fixed attributes, you remove the per-object dict. The result? Smaller memory footprint and often faster attribute access.


Normal Class vs slots

A regular class stores attributes in a dict. With millions of instances, this overhead adds up.

class UserNormal:
    def __init__(self, uid, name, active):
        self.uid = uid
        self.name = name
        self.active = active

class UserSlots:
    __slots__ = ("uid", "name", "active")
    def __init__(self, uid, name, active):
        self.uid = uid
        self.name = name
        self.active = active

With UserSlots, Python knows exactly what attributes exist. No dict means less memory per object.


Attribute Restrictions

The trade-off: slots forbids adding new attributes not listed in the slots.

u = UserSlots(1, "alice", True)

u.email = "alice@example.com"   # ❌ raises AttributeError

This can actually be a feature — it prevents typos and keeps object structure predictable.


Attribute Access Speed

Because slots bypass the dictionary lookup, attribute access is often slightly faster.

u_norm = UserNormal(1, "alice", True)
u_slots = UserSlots(1, "alice", True)

def read_norm(): return u_norm.uid
def read_slots(): return u_slots.uid

import timeit
print(timeit.timeit(read_norm, number=2_000_000))
print(timeit.timeit(read_slots, number=2_000_000))

Expect slots to be a bit faster, but the real gain is memory savings.


Key Points

  • Use slots in object-heavy apps to cut memory use.
  • Slight speedup in attribute access.
  • You cannot add arbitrary attributes.
  • If needed, include "weakref" in slots for weak references.
  • Python 3.10+: @dataclass(slots=True) auto-generates slots.

Code Snippet:

import tracemalloc          # measure memory usage by taking snapshots
import timeit               # quick micro-benchmarks for attribute access


# Define two equivalent classes: one normal, one with __slots__
class UserNormal:
    # regular class has a per-instance __dict__ (flexible but heavier)
    def __init__(self, uid, name, active):
        self.uid = uid                 # set attribute 'uid'
        self.name = name               # set attribute 'name'
        self.active = active           # set attribute 'active'

class UserSlots:
    __slots__ = ("uid", "name", "active")  # declare fixed attributes; no __dict__ by default
    def __init__(self, uid, name, active):
        self.uid = uid                      # set attribute 'uid'
        self.name = name                    # set attribute 'name'
        self.active = active                # set attribute 'active'

def make_users(cls, n=100_000):
    # helper to create n instances with small strings/ints
    return [cls(i, f"user{i}", (i % 2 == 0)) for i in range(n)]

# Measure memory for normal class instances
tracemalloc.start()                                     # begin tracking allocations
_ = make_users(UserNormal, n=100_000)                   # create many normal instances
snap_normal = tracemalloc.take_snapshot()               # snapshot memory after creation
tracemalloc.stop()                                      # stop tracking

# Measure memory for slots class instances
tracemalloc.start()                                     # restart tracking for a clean measurement
_ = make_users(UserSlots, n=100_000)                    # create many slots instances
snap_slots = tracemalloc.take_snapshot()                # snapshot memory after creation
tracemalloc.stop()                                      # stop tracking

# Compute total allocated size for each snapshot (sum all traces)
total_normal = sum(stat.size for stat in snap_normal.statistics('filename'))
total_slots  = sum(stat.size for stat in snap_slots.statistics('filename'))

print(f"Total allocated (normal): {total_normal/1024/1024:.2f} MiB")
print(f"Total allocated (slots) : {total_slots/1024/1024:.2f} MiB")
print(f"Memory reduction (~): {(1 - (total_slots/total_normal)) * 100:.1f}%")


u = UserSlots(1, "alice", True)     # create a slots-based instance

try:
    u.email = "alice@example.com"   # ❌ adding new attribute not in __slots__ will fail
except AttributeError as e:
    print("Expected AttributeError:", e)

# If you need weak references, include "__weakref__" in __slots__
class UserSlotsWeak:
    __slots__ = ("uid", "name", "active", "__weakref__")  # allows weakref support if needed
    def __init__(self, uid, name, active):
        self.uid = uid
        self.name = name
        self.active = active


# Prepare one instance of each for fair comparison
u_norm = UserNormal(1, "alice", True)
u_slots = UserSlots(1, "alice", True)

def read_norm():
    # read a couple of attributes; return something so it's not optimized away
    return u_norm.uid + (1 if u_norm.active else 0)

def read_slots():
    # same workload for the slots-based instance
    return u_slots.uid + (1 if u_slots.active else 0)

# timeit returns total seconds for the given number of loops; lower is better
t_norm  = timeit.timeit(read_norm, number=2_000_000)   # run many iterations
t_slots = timeit.timeit(read_slots, number=2_000_000)

print(f"Attribute read (normal): {t_norm:.3f}s")
print(f"Attribute read (slots) : {t_slots:.3f}s")
speedup = (t_norm / t_slots) if t_slots else float('inf')
print(f"Speedup (≈): {speedup:.2f}x")

Link copied!

Comments

Add Your Comment

Comment Added!