Problem. A Python class that stores each field in an instance dictionary pays for that dictionary on every object.
Baseline. The plain class below has three attributes and an instance __dict__.
Solution. A slotted class declares x, y and z in __slots__, removing the per-instance dictionary for those fields.[1]
Measured result. On CPython 3.13.5, 100,000 slotted records used 38.46% less traced memory, while construction and field access were slower in this run.
Reproduce the result
Complete code
from __future__ import annotationsimport gcimport platformimport statisticsimport sysimport timeitimport tracemallocN = 100_000REPEAT = 7WARMUPS = 2MEMORY_REPEATS = 5class PlainRecord: def __init__(self, x: int, y: int, z: int) -> None: self.x = x self.y = y self.z = zclass SlottedRecord: __slots__ = ("x", "y", "z") def __init__(self, x: int, y: int, z: int) -> None: self.x = x self.y = y self.z = zdef build_plain(count: int) -> list[PlainRecord]: return [PlainRecord(1, 2, 3) for _ in range(count)]def build_slotted(count: int) -> list[SlottedRecord]: return [SlottedRecord(1, 2, 3) for _ in range(count)]def sum_fields(records: list[PlainRecord] | list[SlottedRecord]) -> int: return sum(record.x + record.y + record.z for record in records)def measure(statement: str, namespace: dict[str, object]) -> list[float]: timer = timeit.Timer(statement, globals=namespace) for _ in range(WARMUPS): timer.timeit(number=1) return timer.repeat(repeat=REPEAT, number=1)def measure_memory(factory, count: int) -> list[int]: samples: list[int] = [] for _ in range(MEMORY_REPEATS): gc.collect() tracemalloc.start() records = factory(count) current, _peak = tracemalloc.get_traced_memory() samples.append(current) del records tracemalloc.stop() gc.collect() return samplesdef format_microseconds(values: list[float]) -> str: return ",".join(f"{value * 1_000_000:.3f}" for value in values)def format_kib(values: list[int]) -> str: return ",".join(f"{value / 1024:.3f}" for value in values)def main() -> None: print(f"python={platform.python_implementation()} {platform.python_version()}") print(f"platform={platform.platform(aliased=True)}") print(f"machine={platform.machine()}") print(f"records={N} warmups={WARMUPS} repeats={REPEAT} memory_repeats={MEMORY_REPEATS}") print("units=microseconds per operation; memory=tracemalloc current bytes; timer=timeit.default_timer") plain_sample = PlainRecord(1, 2, 3) slotted_sample = SlottedRecord(1, 2, 3) plain_records = build_plain(N) slotted_records = build_slotted(N) expected = N * 6 assert sum_fields(plain_records) == expected assert sum_fields(slotted_records) == expected try: slotted_sample.extra = 4 except AttributeError: dynamic_attribute = "AttributeError" else: dynamic_attribute = "accepted" assert dynamic_attribute == "AttributeError" memory_namespace = { "N": N, "build_plain": build_plain, "build_slotted": build_slotted, } plain_memory = measure_memory(build_plain, N) slotted_memory = measure_memory(build_slotted, N) plain_memory_median = statistics.median(plain_memory) slotted_memory_median = statistics.median(slotted_memory) reduction = (plain_memory_median - slotted_memory_median) / plain_memory_median * 100 time_namespace = { "N": N, "plain_records": plain_records, "slotted_records": slotted_records, "build_plain": build_plain, "build_slotted": build_slotted, "sum_fields": sum_fields, } plain_build = measure("build_plain(N)", time_namespace) slotted_build = measure("build_slotted(N)", time_namespace) plain_access = measure("sum_fields(plain_records)", time_namespace) slotted_access = measure("sum_fields(slotted_records)", time_namespace) print( f"sample_size_bytes=plain:{sys.getsizeof(plain_sample)} " f"plain_dict:{sys.getsizeof(plain_sample.__dict__)} " f"slotted:{sys.getsizeof(slotted_sample)}" ) print(f"plain_memory_samples_kib={format_kib(plain_memory)}") print(f"slotted_memory_samples_kib={format_kib(slotted_memory)}") print( f"plain_memory_median_kib={plain_memory_median / 1024:.3f} " f"slotted_memory_median_kib={slotted_memory_median / 1024:.3f} " f"reduction_percent={reduction:.2f}" ) print(f"plain_build_samples_us={format_microseconds(plain_build)}") print(f"slotted_build_samples_us={format_microseconds(slotted_build)}") print( f"plain_build_median_us={statistics.median(plain_build) * 1_000_000:.3f} " f"slotted_build_median_us={statistics.median(slotted_build) * 1_000_000:.3f}" ) print(f"plain_access_samples_us={format_microseconds(plain_access)}") print(f"slotted_access_samples_us={format_microseconds(slotted_access)}") print( f"plain_access_median_us={statistics.median(plain_access) * 1_000_000:.3f} " f"slotted_access_median_us={statistics.median(slotted_access) * 1_000_000:.3f}" ) print(f"checksums=plain:{sum_fields(plain_records)} slotted:{sum_fields(slotted_records)}") print(f"dynamic_attribute={dynamic_attribute}")if __name__ == "__main__": main()
Command
python3 benchmark/slots_memory.py
Output
python=CPython 3.13.5platform=Linux-6.12.47+rpt-rpi-v8-aarch64-with-glibc2.41machine=aarch64records=100000 warmups=2 repeats=7 memory_repeats=5units=microseconds per operation; memory=tracemalloc current bytes; timer=timeit.default_timersample_size_bytes=plain:48 plain_dict:96 slotted:56plain_memory_samples_kib=10157.211,10157.211,10157.211,10157.211,10157.211slotted_memory_samples_kib=6251.023,6251.023,6251.023,6251.023,6251.023plain_memory_median_kib=10157.211 slotted_memory_median_kib=6251.023 reduction_percent=38.46plain_build_samples_us=55936.257,51414.345,54767.543,53504.274,52728.485,55348.724,54785.451slotted_build_samples_us=61702.307,64073.457,65064.097,63429.980,63944.254,64529.398,64578.453plain_build_median_us=54767.543 slotted_build_median_us=64073.457plain_access_samples_us=13636.104,14040.305,13644.456,14015.508,13866.917,13619.715,14116.100slotted_access_samples_us=14481.764,14860.891,15096.519,14521.412,14707.559,14920.613,13734.733plain_access_median_us=13866.917 slotted_access_median_us=14707.559checksums=plain:600000 slotted:600000dynamic_attribute=AttributeError
Environment
The command ran on CPython 3.13.5, Linux 6.12.47+rpt-rpi-v8, aarch64, with the Python standard library only. Each factory creates 100,000 records with the same three small integer values, so the comparison focuses on object storage rather than different payloads.
Methodology
The program measures construction and attribute access with timeit.Timer: two untimed warm-ups precede seven timed calls for each operation.[3] The output keeps every sample and reports the median. Python’s timeit path temporarily disables garbage collection during timed calls by default.[3]
For memory, the program collects garbage, starts tracemalloc, builds the record list, reads the current traced allocation, then deletes the list and stops tracing. tracemalloc traces memory blocks allocated by Python, so this is a Python-allocation comparison rather than a process-RSS measurement.[2] The sample sizes come from sys.getsizeof; that function reports the bytes directly attributed to an object and excludes objects it refers to.[4]
Experiment limits
This is one CPython build on one Raspberry Pi. It measures 100,000 instances with three integer fields and no extra per-record metadata. It does not measure RSS, serialisation, garbage-collection pauses, alternate Python implementations, concurrent access or a numeric array designed for bulk data. The timing result is a property of this code and this host.
Why the memory falls
A normal instance stores writable attributes through an instance dictionary. __slots__ declares the allowed names at class definition time and prevents the automatic creation of that dictionary for each instance.[1] That removes one object per record in this example, while the three field values remain ordinary Python objects.
The direct size probe shows the shape of the trade-off: the plain instance is 48 bytes, its __dict__ is 96 bytes, and the slotted instance is 56 bytes. Those figures are component sizes, not a complete memory bill. The benchmark records current Python allocation after imports, class setup, sample objects and the pre-built lists already exist. tracemalloc traces Python memory blocks.[2] Allocations made before tracing, native allocations and process RSS sit outside this measurement, so the two figures describe this allocation window rather than a full per-process memory total.
What the measurement shows
The plain class used a median 10,157.211 KiB. The slotted class used 6,251.023 KiB, a reduction of 38.46% for this object count and field layout. The two aggregate checksums were both 600,000; that check does not prove per-record or per-field equality.
The memory result came with a time cost. Building the plain records took a median 54,767.543 microseconds; the slotted build took 64,073.457 microseconds. Summing the three fields took 13,866.917 microseconds for the plain list and 14,707.559 microseconds for the slotted list. The documentation notes that attribute lookup speed can improve with slots, but this particular loop did not show that improvement.[1]
The useful decision is therefore a capacity question. If thousands or millions of fixed-shape objects dominate memory, the saved dictionaries may matter more than a modest construction or access difference. If the objects are few, frequently rebuilt or expected to gain ad-hoc attributes, the plain class keeps a simpler interface.
Practical applications
- A graph or abstract-syntax-tree node with fixed fields can use slots when a large graph must remain in memory. The benchmark measures record storage, not graph traversal; parent links, annotations and optional metadata would change the result.
- A parser can represent tokens or protocol messages with declared fields and avoid a dictionary per record. This fits a stable schema. A parser that attaches arbitrary diagnostics needs a separate side table or a normal instance dictionary.
- A simulation can store many small particles or coordinates as slotted objects when object-oriented access matters. A packed array may use less memory for homogeneous numeric data, and that alternative was not measured here.
- An in-memory cache can use fixed-shape entries for predictable metadata such as a value, timestamp and state. Subclasses must define their own slots to keep the layout constrained, and weak-reference support needs an explicit
__weakref__slot.[1]
Where slots stop helping
A slotted instance rejects an attribute name that is absent from __slots__. That is useful for a fixed schema, but it also removes the convenient escape hatch of attaching a new field at runtime. The class raises AttributeError for that assignment.[1]
Inheritance needs care. A subclass that omits __slots__ receives a __dict__, while a subclass with __slots__ = () keeps the restriction.[1] Weak references also need an explicit slot when the design requires them. These are interface constraints, not footnotes to ignore after the memory graph has gone live.
The benchmark answers a narrow question: what happens to a fixed three-field record on this interpreter when the per-instance dictionary is removed? It does not establish a universal speed gain. The captured run found a memory reduction and slower timed operations, so the right test for a real program is its object count, field shape and access pattern.
Sources
[1] object.__slots__, Python 3.13 language reference
[2] tracemalloc, Python 3.13 documentation