Python source can hide the amount of construction it requests. A comprehension creates a list and perhaps thousands of element objects. Splitting text creates substrings. Converting an iterator to a list creates a pointer array. A tidy chain of transformations can build several complete intermediate collections before producing one answer.
Allocation is not automatically bad. Objects are Python's normal unit of meaning, and CPython's allocator makes many small allocations surprisingly cheap. The mistake is treating allocation as free, or treating a lower allocation count as proof of a faster program. Allocation consumes CPU, changes cache behavior, raises peak memory, and can make garbage collection or reference-count cleanup visible. Removing it may also make code slower, less clear, or semantically wrong.
This tutorial measures those tradeoffs with only the standard library. Run each Python block by saving it as experiment.py and invoking .venv/bin/python experiment.py; the one-line benchmarks are directly executable commands.
Test environment. Recorded results came from CPython 3.14.7, arm64, macOS 26.5.2, traditional GIL enabled, optimization level 0, non-debug build. Exact sizes and timings are CPython, build, architecture, allocator, and operating-system observations. Python does not specify object headers, allocator behavior, or RSS.
Experiment 1: a temporary list is real work
Compare two reductions over exactly the same integers:
.venv/bin/python -m timeit -r 5 -n 100 -s 'data = range(10000)' 'sum([x * x for x in data])'
.venv/bin/python -m timeit -r 5 -n 100 -s 'data = range(10000)' 'sum(x * x for x in data)'
The first expression builds a 10,000-element list, passes it to sum, then releases it. The second feeds values through a generator expression. Our best times were 337 us for each, with enough variation that there was no defensible speed winner.
That result is more useful than the slogan "generators are faster." The list comprehension runs its iteration efficiently and gives sum a concrete sequence; the generator avoids the list but resumes Python generator machinery for each value. One candidate spends work materializing, while the other spends work suspending and resuming. Workload and interpreter version choose the winner.
Both produce the same numeric result, but they do not have the same interface in general. A materialized list can be indexed, measured, traversed repeatedly, and mutated. An iterator is single-pass and can observe its source lazily. Choose semantics first. When a downstream consumer needs one pass, peak memory can break a timing tie.
Experiment 2: measure the traced peak
tracemalloc traces Python memory allocations and can report current and peak traced bytes. Start tracing before the operation under investigation:
import tracemalloc
def measure(materialize):
tracemalloc.start()
tracemalloc.reset_peak()
values = (x * x for x in range(100_000))
result = sum(list(values) if materialize else values)
current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
return result, current, peak
print("list: ", measure(True))
print("generator:", measure(False))
Our list run peaked at 3,997,280 traced bytes; the generator run peaked at 408 bytes. Both returned 333328333350000. Current memory was only a few kilobytes or less because the temporary values were no longer live when queried.
Peak answers "how much traced allocation existed simultaneously since tracing or the last reset?" Current answers "how much traced allocation is live now?" Neither number is whole-process resident memory. tracemalloc traces allocations made through Python's memory allocators; native libraries can allocate outside its view. Tracing itself also costs time and memory, so use it to explain allocation, then benchmark speed in a separate untraced process.
Starting tracing late omits earlier allocations. Conversely, importing a large application after starting it can bury the operation in import noise. Put the boundary deliberately around cold start, request handling, or a specific batch according to the question.
Experiment 3: snapshots identify retained allocations
A peak says how much, not where. Snapshots record traceback statistics for allocations that are live at the snapshot:
import gc
import tracemalloc
tracemalloc.start(10)
before = tracemalloc.take_snapshot()
held = [[0] * 100 for _ in range(1_000)]
after = tracemalloc.take_snapshot()
for stat in after.compare_to(before, "lineno")[:5]:
print(stat)
del held
gc.collect()
print("current, peak:", tracemalloc.get_traced_memory())
On our run, the construction line accounted for about 864 KB and 1,994 additional live allocations. After deleting held and collecting, current traced memory fell to roughly 1.6 KB while the recorded peak remained about 868 KB.
Use compare_to rather than staring at one large snapshot. A before/after difference narrows the question to allocations retained across a meaningful operation. Group by lineno to find a statement, filename to find a component, or traceback when call path matters. Increase the traceback depth only when the extra diagnostic value justifies its overhead.
A snapshot does not show objects that were allocated and freed entirely between snapshots. get_traced_memory() can reveal their peak, and repeated snapshots can reveal retention, but allocation-rate profiling may require platform or third-party tools. Also distinguish a cache intentionally retaining useful objects from a leak. Growth is evidence to inspect, not a diagnosis.
Experiment 4: shallow size exposes object headers and pointers
sys.getsizeof() reports an object's shallow size: the bytes directly attributed to that object, not everything reachable through it.
import array
import sys
count = 100_000
boxed = list(range(count))
packed = array.array("q", range(count))
print("int object:", sys.getsizeof(0))
print("empty list:", sys.getsizeof([]))
print("one-slot list:", sys.getsizeof([None]))
print("list only:", sys.getsizeof(boxed))
print("list plus distinct ints:", sys.getsizeof(boxed) + sum(map(sys.getsizeof, boxed)))
print("packed array:", sys.getsizeof(packed))
Our 64-bit CPython reported 28 bytes for 0, 56 for an empty list, and 64 for a one-element list. The 100,000-element list's pointer storage was 800,056 bytes. Counting its distinct integer objects raised the illustrative total to 3,600,056 bytes; the signed 64-bit array used 816,640 bytes.
The recursive sum is valid here because range(100_000) produced distinct integer objects retained by this list. Blind recursive size recipes often double-count shared references. [shared] * 100_000 has 100,000 pointers but only one shared object. Ownership must be defined before totals mean anything.
This representation difference also affects locality. An array stores fixed-width values together. A list stores pointers together, while the pointed-to Python objects live elsewhere and carry type and reference-management metadata. Traversal of pointer-heavy structures can require more memory traffic and less predictable cache access. CPU cache effects are implementation and hardware behavior, not a Python language guarantee, and getsizeof() does not measure them directly.
Packed storage has costs: constrained element types, conversion at boundaries, and fewer convenient object semantics. Use it for genuinely dense homogeneous data, not to save dozens of bytes in a small control structure.
Experiment 5: record shape changes footprint
Ordinary instances commonly have an attribute dictionary. Slots can remove that per-instance mapping when dynamic attributes and a normal __dict__ are unnecessary:
import sys
class Record:
def __init__(self, x, y):
self.x = x
self.y = y
class SlottedRecord:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x = x
self.y = y
records = [Record(i, i) for i in range(100_000)]
slotted = [SlottedRecord(i, i) for i in range(100_000)]
regular_bytes = sum(sys.getsizeof(r) + sys.getsizeof(r.__dict__) for r in records)
slotted_bytes = sum(sys.getsizeof(r) for r in slotted)
print(sys.getsizeof(records[0]), sys.getsizeof(records[0].__dict__))
print(sys.getsizeof(slotted[0]))
print(regular_bytes, slotted_bytes)
Our shallow totals were 13.6 MB for regular instances plus their dictionaries and 4.8 MB for slotted instances. Both exclude the outer lists and referenced integers; those are shared equally between candidates in this experiment. CPython's key-sharing instance dictionaries already reduce the regular representation's cost, so do not extrapolate from an old object-layout diagram.
__slots__ is a data-model decision, not a universal optimization switch. It changes weak-reference support, dynamic attributes, multiple-inheritance constraints, introspection, and serialization expectations. dataclass(slots=True) can make the choice convenient, but it does not remove those semantics. Measure complete application objects and test their required behavior.
Experiment 6: reuse only across a natural ownership boundary
If an operation repeatedly needs the same large scratch buffer, reuse can eliminate allocation and initialization:
.venv/bin/python -m timeit -r 5 -n 10000 -s 'size = 65536' 'buf = bytearray(size); buf[:4] = b"data"'
.venv/bin/python -m timeit -r 5 -n 10000 -s 'buf = bytearray(65536)' 'buf[:4] = b"data"'
Our best times were 5.66 us per new 64 KiB buffer and 0.10 us per update of an existing buffer. This intentionally isolates construction, so it does not claim that a real parser becomes 56 times faster. If parsing takes milliseconds, buffer allocation may be irrelevant.
Reuse is safest when ownership is already obvious: one worker owns a scratch buffer, a protocol offers readinto(), or a batch loop clears and refills a private list. Reset all observable state, prevent references from escaping, and account for concurrency. A reused container can retain references or oversized capacity; list.clear() releases element references but does not promise to return all storage to the operating system.
Do not build a general object pool because allocation appeared in a profile. Pools add synchronization, lifetime bugs, stale state, memory retention, and tuning knobs. CPython already has specialized allocators and internal free lists. Add application-level pooling only when representative end-to-end measurements show material allocation pressure and natural reuse is unavailable.
Experiment 7: live, peak, and RSS answer different questions
On Unix-like systems, resource.getrusage() exposes maximum resident set size. Compare it with traced memory before and after releasing a burst:
import gc
import resource
import tracemalloc
tracemalloc.start()
blocks = [bytearray(1_024) for _ in range(50_000)]
print("during traced:", tracemalloc.get_traced_memory())
print("during maxrss:", resource.getrusage(resource.RUSAGE_SELF).ru_maxrss)
del blocks
gc.collect()
print("after traced:", tracemalloc.get_traced_memory())
print("after maxrss:", resource.getrusage(resource.RUSAGE_SELF).ru_maxrss)
In our already-running macOS process, traced memory went from about 54.5 MB live to under 1 KB live. Its traced peak stayed about 54.5 MB, and ru_maxrss stayed at about 141 MB because it is a historical high-water mark, not current RSS. On macOS ru_maxrss is bytes; Linux reports KiB. Windows does not provide this resource interface.
Released Python objects do not imply an immediate RSS drop. CPython's allocators may retain arenas for future allocations, the C library may retain pages, and the OS controls residency. That retained capacity can make the next burst cheaper and is not by itself a leak. For current RSS and native allocations, use operating-system tooling appropriate to deployment; for Python allocation tracebacks, use tracemalloc; for peak capacity planning, measure fresh representative processes under realistic traffic.
Never compare a tracemalloc peak from one boundary with container RSS from another and call the difference "Python overhead." They account for different memory at different layers.
Experiment 8: bounded materialization is often the practical answer
The choice is not always "load everything" versus "never materialize." Process a stream in bounded batches:
from itertools import batched
def source(limit):
for value in range(limit):
yield value
total = 0
for chunk in batched(source(1_000_000), 1_000):
# One tuple of at most 1,000 inputs is live here.
total += sum(value * value for value in chunk)
print("[result] bounded-batch sum of squares:", total)
itertools.batched was added in Python 3.12, so use an equivalent small batching helper when supporting older versions. A batch can improve locality and amortize database, serialization, or function-call overhead while keeping memory proportional to batch size. It also introduces partial-failure and retry decisions: if batch 417 fails, can it be replayed safely?
Choose batch size from end-to-end throughput, latency, memory headroom, and downstream limits. A power of two has no inherent virtue here. Measure several realistic sizes and retain the smallest batch that captures most of the benefit.
A practical allocation review
When memory or CPU profiles point toward allocation, work in this order:
- Verify the hot path with production-shaped inputs.
- State whether the problem is latency, allocation rate, live heap, peak heap, or process RSS.
- Remove unnecessary complete intermediates where one-pass semantics are correct.
- Prefer a better representation for large homogeneous data or repeated fixed-shape records.
- Batch at an existing I/O or processing boundary.
- Reuse private scratch state only when ownership and reset are simple.
- Re-measure without tracing overhead and check end-to-end behavior.
Do not contort cold code to avoid a tuple, intern unbounded user data, call gc.collect() on every request, or copy containers ritualistically. Those moves trade a suspected allocator cost for certain complexity.
Exercises
- Add one intermediate
listto a three-stage data pipeline. Measure elapsed time, current traced bytes, and traced peak separately; explain why they move differently. - Compare a list of one million booleans with a
bytearraycontaining zeroes and ones. Define what semantics are lost before discussing bytes saved. - Take snapshots before and after warming an
functools.lru_cache. Decide whether retained entries are useful capacity, excessive retention, or a leak for your workload. - Compare ordinary and slotted records while including outer containers and shared referents exactly once. Test weak references and serialization before choosing.
- Process a generated input in batches of 1, 100, 1,000, and 10,000. Plot throughput and traced peak memory, then choose a batch from requirements rather than the fastest isolated result.
Keep this model
Allocation is work, but bytes and objects are not interchangeable measurements. A pointer-heavy Python representation pays for flexible object semantics. A packed representation trades those semantics for density and locality. A generator can reduce peak memory without reducing CPU time. Released objects can make live traced memory fall while peak and RSS remain high.
Name the metric, define ownership, and measure at a boundary users actually experience. Remove materialization when the consumer is truly one-pass; choose compact layouts when data is truly homogeneous; reuse only where lifetime is naturally bounded. The goal is not zero allocation. It is paying for objects that carry useful meaning.