Your program keeps a million sensor readings in memory. Each one is a small
dataclass with two fields. A plain Python object stores its attributes in
its own dictionary, __dict__, so you can add any attribute at any time.
Across a million objects, that storage costs tens of megabytes.
from dataclasses import dataclass
@dataclass
class Reading:
sensor: int
value: float
r = Reading(1, 0.5)
r.note = "checked" # adds a third attribute
print(r.__dict__) # {'sensor': 1, 'value': 0.5, 'note': 'checked'}
mypy reports the r.note line, but Python runs it.
Store the fields in fixed slots
With slots=True, each object gets a fixed slot for each field and no
__dict__. A slot is room for one named attribute, reserved inside the
object itself. The decorator builds the class with __slots__, the class
attribute that tells Python which attributes the objects have room for.
from dataclasses import dataclass
@dataclass(slots=True)
class SlimReading:
sensor: int
value: float
r = SlimReading(1, 0.5)
print(SlimReading.__slots__) # ('sensor', 'value')
r.value = 0.7 # fields can still change
r.note = "checked" # raises AttributeError
The object now has room for its two fields and nothing else. That makes it
smaller. It also makes a typo such as r.vaule = 0.7 raise
AttributeError instead of adding a new attribute. Slots do not freeze the
fields, so add frozen=True as well if they should not change.
Measure the saving
Measure your own records before you switch. tracemalloc counts the memory
Python allocates while tracing is on:
import tracemalloc
from dataclasses import dataclass
@dataclass
class Reading:
sensor: int
value: float
@dataclass(slots=True)
class SlimReading:
sensor: int
value: float
def bytes_per_record(cls: type[Reading] | type[SlimReading]) -> float:
tracemalloc.start()
records = [cls(1, 0.5) for _ in range(100_000)]
size, _peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
return size / len(records)
print(f"Reading: {bytes_per_record(Reading):.0f} bytes")
print(f"SlimReading: {bytes_per_record(SlimReading):.0f} bytes")
On Python 3.12 this prints 88 bytes for Reading and 56 for
SlimReading. On 3.14 and 3.15 a Reading takes 96 bytes. For a million
records, that is 32 to 40 MB saved. If each record also held a long string,
the string would take most of the memory and the saving would matter less.
Check your understanding
Why measure with tracemalloc instead of comparing sys.getsizeof(r)?
Show answer
Hide answer
sys.getsizeof returns 48 for both a Reading and a SlimReading on
Python 3.12 to 3.15. It counts only the object's own block of memory, and a
plain object keeps its attribute storage outside that block. tracemalloc
counts every allocation, so it shows the difference.
Check what the class gives up
A slotted class behaves differently in a few ways. Check each one before
you add slots=True:
- Code that sets an attribute that is not a field, or reads
self.__dict__, raisesAttributeError. cached_propertystores its result in__dict__, so reading one raisesTypeError. Use a plainproperty, or a field you fill in yourself.weakref.ref(r)raisesTypeError, because a weak reference also needs a slot. Addweakref_slot=Truenext toslots=Trueif code needs weak references.- A subclass that is not slotted gets a
__dict__again, and so does a slotted class whose base is a plain class. Give every class in the hierarchyslots=True. - Python reads
__slots__only when it creates a class, so the decorator cannot add slots to your class. It builds a second class and returns that one. Code that captured the first class during creation still points at the old one.
The next exercise, Check a Class Before You Add slots=True, looks for these problems in code.
Check your understanding
You add slots=True to a base dataclass Shape, but class Circle(Shape) is a plain subclass. Do Circle objects save memory?
Show answer
Hide answer
Less than you expect. Circle has no __slots__ of its own, so Python
gives its objects a __dict__ again. That takes back some or all of the
saving, depending on the Python version. Decorate Circle with
@dataclass(slots=True) too.
Going further
- You can write
__slots__ = ("sensor", "value")by hand in an ordinary class.slots=Truewrites it from the fields for you. - Read
__slots__to see a class's storage, not its fields. Since Python 3.11 it leaves out names a base class already has a slot for. Calldataclasses.fields()for the field list.
See Python's slots reference, the dataclass options and the tracemalloc documentation.