AW Dev Rethought

βœ‚οΈ Perfection is achieved not when there is nothing more to add, but when there is nothing left to take away - Antoine de Saint-ExupΓ©ry

πŸ”¬ Python Code Lab β€” Does __slots__ Really Save Memory?


Description:

Python objects are flexible by design. Attributes can usually be added or modified dynamically, making classes easy to work with.

But that flexibility has a memory cost.

Python's __slots__ is often suggested as a way to reduce the memory consumed by class instances. Instead of simply accepting that claim, let's measure what happens when thousands of regular and slotted objects are created.


What Are We Testing?

We'll compare two classes containing exactly the same data:

  • RegularUser β€” a normal Python class
  • SlottedUser β€” a class defining its attributes with __slots__

We'll then create increasing numbers of instances and measure their memory usage using Python's tracemalloc module.

The goal is to answer a simple question:

Does __slots__ actually reduce memory usage, and does the difference become more significant as the number of objects grows?


Creating the Classes

First, we create two equivalent classes.

class RegularUser:
    def __init__(self, user_id, name, email):
        self.user_id = user_id
        self.name = name
        self.email = email

class SlottedUser:
    __slots__ = ("user_id", "name", "email")

    def __init__(self, user_id, name, email):
        self.user_id = user_id
        self.name = name
        self.email = email

Both store the same three values.

The important difference is that SlottedUser explicitly declares which instance attributes are allowed.


Measuring Memory Usage

For the experiment, we use tracemalloc to track memory allocated while the objects are being created.

def measure_memory(user_class, count):
    gc.collect()
    tracemalloc.start()

    users = [
        user_class(i, f"User {i}", f"user{i}@example.com")
        for i in range(count)
    ]

    current, peak = tracemalloc.get_traced_memory()
    tracemalloc.stop()

    return current, peak

Garbage collection runs before each measurement to reduce interference from objects created during previous tests.

The objects remain alive when the measurement is taken, allowing us to compare the memory associated with each collection.


Scaling the Experiment

Testing only a few objects would make the difference difficult to appreciate.

Instead, we repeat the measurement at several scales:

object_counts = [1_000, 10_000, 50_000, 100_000]

For every object count, the program creates both regular and slotted objects and calculates the memory difference.

saving = regular_current - slotted_current
saving_percent = (saving / regular_current) * 100

This lets us observe not only whether __slots__ saves memory, but also how that saving behaves as more objects are created.

The exact measurements will vary between Python versions, operating systems, and runtime environments, so the experiment focuses on the relative difference rather than expecting one universal number.


Why Does __slots__ Use Less Memory?

Regular Python class instances normally maintain their attributes through an instance dictionary called __dict__.

That provides useful flexibility, including the ability to add new attributes dynamically.

We can verify its presence directly:

print("RegularUser has __dict__:", hasattr(regular_user, "__dict__"))
print("SlottedUser has __dict__:", hasattr(slotted_user, "__dict__"))

For the simple classes used in this experiment, the regular instance has a __dict__, while the slotted instance does not.

By declaring the expected attributes in advance, __slots__ can avoid that per-instance dictionary overhead.

When only a few objects exist, this difference may not matter much. When tens or hundreds of thousands of similar objects exist simultaneously, those per-object savings can accumulate.


There's a Trade-Off

The reduced memory overhead does not come completely free.

A normal object allows us to do this:

regular_user.role = "admin"

But role was not declared in SlottedUser.__slots__.

Attempting the same operation therefore raises an AttributeError:

slotted_user.role = "admin"

This illustrates the main trade-off: __slots__ provides a more fixed instance structure in exchange for some of the dynamic attribute flexibility available with regular Python objects.


Key Findings

  • __slots__ can reduce the per-instance memory overhead of Python objects.
  • The benefit becomes easier to observe when many instances of the same class exist.
  • Regular instances normally use __dict__ for instance attributes, while simple slotted classes can avoid it.
  • __slots__ restricts arbitrary new instance attributes unless they are explicitly supported.
  • Actual memory savings depend on the Python implementation, version, platform, class structure, and measurement method.

Conclusion

__slots__ really can save memory, particularly when an application maintains large numbers of objects with the same fixed set of attributes.

It is not automatically the right choice for every class. For a small number of objects, the memory reduction may not justify giving up some of Python's dynamic flexibility.

The useful question is therefore not simply "Does __slots__ save memory?", but "Does it save enough memory to matter for this workload?"

And that is exactly the kind of question worth measuring.


Code Snippet:

import gc
import tracemalloc


class RegularUser:
    def __init__(self, user_id, name, email):
        self.user_id = user_id
        self.name = name
        self.email = email


class SlottedUser:
    __slots__ = ("user_id", "name", "email")

    def __init__(self, user_id, name, email):
        self.user_id = user_id
        self.name = name
        self.email = email


def measure_memory(user_class, count):
    gc.collect()
    tracemalloc.start()

    users = [
        user_class(i, f"User {i}", f"user{i}@example.com")
        for i in range(count)
    ]

    current, peak = tracemalloc.get_traced_memory()
    tracemalloc.stop()

    return current, peak


object_counts = [1_000, 10_000, 50_000, 100_000]

results = []

for count in object_counts:
    regular_current, regular_peak = measure_memory(RegularUser, count)
    slotted_current, slotted_peak = measure_memory(SlottedUser, count)

    saving = regular_current - slotted_current
    saving_percent = (saving / regular_current) * 100

    results.append(
        (
            count,
            regular_current,
            slotted_current,
            saving,
            saving_percent,
        )
    )


print(f"{'Objects':>10} {'Regular (MB)':>15} {'Slots (MB)':>15} {'Saved (MB)':>15} {'Saved (%)':>12}")
print("-" * 72)

for count, regular, slotted, saving, saving_percent in results:
    print(f"{count:>10,} {regular / 1024**2:>15.2f} {slotted / 1024**2:>15.2f} {saving / 1024**2:>15.2f} {saving_percent:>11.2f}%")


regular_user = RegularUser(1, "Alice", "alice@example.com")
slotted_user = SlottedUser(1, "Alice", "alice@example.com")

print("\nRegularUser has __dict__:", hasattr(regular_user, "__dict__"))
print("SlottedUser has __dict__:", hasattr(slotted_user, "__dict__"))


regular_user.role = "admin"
print("\nRegularUser role:", regular_user.role)

try:
    slotted_user.role = "admin"
except AttributeError as error:
    print("SlottedUser:", error)

Link copied!

Comments

Add Your Comment

Comment Added!