Deleting the last reference to an object and returning memory to the operating system are different events. CPython may destroy the object promptly while retaining its storage in an allocator pool for later Python allocations. Libraries may maintain free lists. The platform allocator may keep freed pages. Process RSS can therefore remain high without a live-object leak.

To investigate memory responsibly, separate four layers: reachable Python objects, CPython's object allocator, the C library and extension allocators, and operating-system accounting. Each tool sees a different layer.

Python guarantee. Python does not specify object addresses, allocator geometry, prompt reclamation, or resident-memory behavior. Resource limits and MemoryError are environment-dependent.

CPython 3.14 detail. The default build uses allocator domains and generally uses pymalloc for memory blocks no larger than 512 bytes. Pymalloc groups blocks by size class into pools and pools into arenas. Exact constants and policies are implementation/version/build details.

Version note. Experiments ran on 64-bit macOS with GIL-enabled CPython 3.14.7. The free-threaded build imposes different allocator-domain requirements. Debug builds, PYTHONMALLOC, architecture, extensions, and platform malloc change measurements.

Experiment 1: object size is shallow

import sys


payload = bytearray(100_000)
container = [payload, payload]

print(sys.getsizeof(container))
print(sys.getsizeof(payload) >= 100_000)
print(container[0] is container[1])

The list's size includes its immediate storage, not the bytearray twice. Recursive size functions need an ownership policy and an identity set to avoid double-counting shared children and cycles. There is no universal "true size" for an object graph shared by caches, requests, and globals.

__sizeof__ reports type-specific base consumption; sys.getsizeof adds garbage-collector overhead where applicable. Both are implementation-facing estimates, not process-memory attribution.

Pymalloc's hierarchy

For small requests, CPython's object allocator avoids sending every operation to general-purpose malloc. An arena is a large mapping obtained from a lower allocator. It is divided into pools. A pool serves one block size class, and blocks satisfy object-memory requests. Freed blocks can be reused quickly by the same process.

One live block can keep its pool occupied; one occupied pool can keep an arena from being released. This internal fragmentation explains why a tiny surviving fraction spread across arenas can retain substantial address space. It does not imply that the allocator forgot which objects died.

Large requests normally bypass pymalloc to a lower-level raw allocator. An object can also own external buffers allocated by extension code, so its Python header size does not reveal total native memory.

Experiment 2: allocation rounds into observable steps

import sys


for length in range(0, 65, 8):
    value = bytes(length)
    print(length, sys.getsizeof(value))

Sizes increase with payload and object overhead; allocation may round requests into size classes beyond what getsizeof reports. Do not infer pool geometry from this output. It demonstrates that logical payload, reported object size, and allocator reservation are distinct quantities.

Experiment 3: tracemalloc compares traced Python allocations

import tracemalloc


tracemalloc.start()
before = tracemalloc.take_snapshot()
records = [{"index": i, "label": str(i)} for i in range(5_000)]
after = tracemalloc.take_snapshot()

stats = after.compare_to(before, "lineno")
print(len(records))
print(sum(stat.size_diff for stat in stats) > 0)
tracemalloc.stop()

Snapshots attribute traced allocations to Python traceback locations. Start tracing early enough to observe the allocations of interest; tracing has overhead and does not retroactively recover history. Snapshot differences are stronger evidence than one total because they identify growth sites.

Tracemalloc tracks allocations made through traced domains. Native extensions can allocate outside its view. A flat tracemalloc total alongside growing RSS points toward native buffers, allocator retention, memory mapping, stack growth, or another process-level source, not automatically a Python leak.

Experiment 4: current and peak answer different questions

import gc
import tracemalloc


tracemalloc.start()
temporary = [bytearray(2_000) for _ in range(2_000)]
during = tracemalloc.get_traced_memory()
del temporary
gc.collect()
after = tracemalloc.get_traced_memory()
tracemalloc.stop()

print(during[0] > after[0])
print(after[1] >= during[1])

Current traced bytes fall while the peak remains. RSS may not mirror either value. Peak is useful for workload sizing; current growth across repeated quiescent checkpoints is useful for leak investigation.

Calling gc.collect() here removes cycle timing as a confounder, but bytearrays are not cyclic. It does not flush every free list, force pymalloc arenas out, or command the OS to lower RSS. "Run GC to free memory" conflates object reachability with allocator policy.

Experiment 5: allocator statistics are implementation diagnostics

import sys


print(hasattr(sys, "_debugmallocstats"))
print(sys.implementation.name)

CPython commonly exposes sys._debugmallocstats(), which writes detailed allocator state to standard error. It is private, output is unstable, and invoking it in an article experiment would be noisy. Use it during controlled CPython diagnosis, never parse it as a production API.

For repeatable low-level experiments, run a separate process and capture stderr, interpreter version, PYTHONMALLOC, build flags, and platform. Process isolation also resets allocator state that otherwise carries between benchmark cases.

Environment controls

PYTHONMALLOC selects allocator configurations, including malloc, pymalloc, and debug variants. PYTHONMALLOCSTATS can print pymalloc statistics on supported builds. These are diagnostic controls, not tuning knobs to change casually in production. Alternate allocators alter performance, fragmentation, and observability.

Debug hooks add forbidden-byte patterns, API checks, and traceback integration. They deliberately cost memory and time. Reproduce suspicious corruption under a debug allocator, but do not compare its byte totals directly with a normal build.

Experiment 6: inspect the configured allocator name safely

import os
import sys


print(os.environ.get("PYTHONMALLOC", "default"))
print(sys._debugmallocstats.__name__ if hasattr(sys, "_debugmallocstats") else "unavailable")

The environment only reports an explicit override; default is a label in this experiment, not a queried runtime allocator. Record startup configuration before interpreting statistics because allocator selection happens during interpreter startup.

Free lists add another reuse layer

Some built-in types retain deallocated objects or pieces in type-specific free lists. CPython can reuse them before returning storage to the general object allocator. Policies change across releases and may be affected by full cyclic collections. Application code should not depend on them.

Interning and immortal objects are different mechanisms. A retained interned string is still a live reachable object; an allocator free block contains no live Python object. Mixing these concepts leads to false leak diagnoses.

Experiment 7: address reuse is not object survival

def make_id():
    value = bytearray(16)
    return id(value)


seen = set()
reused = False
for _ in range(20_000):
    identifier = make_id()
    if identifier in seen:
        reused = True
        break
    seen.add(identifier)

print(isinstance(reused, bool))
print(len(seen) <= 20_000)

Reuse may or may not occur in this run, so the experiment asserts only portable facts. If an ID repeats, it means non-overlapping lifetimes can share an ID; it does not mean the old bytearray returned. Never put bare id() values in a long-lived set to track objects without also managing lifetime and reuse.

Benchmark allocations, not folklore

Allocation work can dominate tight loops, but eliminating allocation may harm clarity or retain larger buffers indefinitely. Measure representative throughput and peak memory. tracemalloc changes timings; use separate runs for timing and attribution. Warmup affects free lists, pools, imports, and specializing interpreter state.

Experiment 8: reuse changes allocation pressure

import tracemalloc


def build_fresh(rounds):
    for _ in range(rounds):
        buffer = bytearray(10_000)
        buffer[0] = 1


tracemalloc.start()
build_fresh(1_000)
current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()

print(current >= 0)
print(peak >= 10_000)

This establishes temporary allocation and a peak, not that pooling is the correct optimization. A reusable buffer introduces ownership, clearing, concurrency, and maximum-size retention questions. First remove unnecessary work algorithmically; then consider reuse at a measured hot boundary.

A disciplined investigation

Reproduce under a steady workload with quiescent checkpoints. Record Python version, build, platform, allocator environment, extension versions, and workload counts. Compare tracemalloc snapshots by traceback. Inspect object counts and retaining paths when traced Python allocations grow. Compare RSS or platform metrics only after defining whether they report resident, private, compressed, or virtual memory.

If live objects grow, find the retaining edge: cache, task, callback, traceback, global, or queue. If live traced bytes stabilize but RSS grows, isolate native extensions and large-buffer patterns in subprocesses. Use platform profilers and extension-specific metrics. If RSS reaches a high-water mark then plateaus under repeated load, allocator retention may be healthy reuse rather than an unbounded leak.

Operationally, process recycling can cap fragmentation for bursty workers, but it is containment, not diagnosis. Size worker limits from measured peaks and recycle on explicit policy. Avoid calling private allocator APIs or malloc_trim-style platform functions without understanding portability and latency consequences.

Reading production memory charts

A sawtooth that falls after each request batch suggests reclamation at some layer. A staircase that reaches a new plateau as workload variety grows may be cache warmup, interned data, allocator high-water behavior, or a leak. A linearly increasing current tracemalloc total with repeated traceback locations is much stronger evidence of retained Python allocations. Correlate charts with workload units, queue depth, cache entries, and deployment events.

Resident set size is not a bill for Python objects. It can include executable pages, shared libraries, thread stacks, memory-mapped files, native arenas, and pages shared with other processes. Container dashboards and operating systems may define working set differently. Record the metric definition before setting a regression threshold.

Sampling profilers and tracemalloc answer complementary questions. Tracemalloc tells where traced blocks were allocated, not which reference keeps their objects alive. Object-graph tools tell retaining paths but can perturb the graph and struggle at production scale. Native heap profilers see extension allocations but may not map them cleanly to Python source. Start with the least invasive evidence and reproduce suspicious behavior in an isolated worker.

Allocator reuse is usually a performance feature. Returning every small free block to the operating system would add synchronization and system-call overhead and could make the next request allocate it again. Judge retention against the service's steady-state envelope and co-tenancy requirements, not against an expectation that RSS must return to startup after every request.

Capacity experiments should include burst and recovery phases. Run enough repetitions to distinguish a plateau from unbounded growth, and use fresh processes for comparable cases. Disable neither garbage collection nor allocator features merely to obtain cleaner numbers unless that altered configuration is the subject of the experiment. Finally, preserve raw measurements and environment details; allocator conclusions without build and workload context age badly across CPython releases.

Repeat measurements before acting: allocator noise, imports, and neighboring processes can easily overwhelm a small claimed improvement.

Exercises

  1. Compare tracemalloc current and peak across repeated batches with quiescent checkpoints.
  2. Measure shallow and recursively owned size for a graph containing shared children; prevent double counting.
  3. Run one script in fresh subprocesses with default and PYTHONMALLOC=malloc; record, but do not overgeneralize, RSS and timing.
  4. Capture _debugmallocstats from CPython 3.14 and identify arenas, pools, and size classes without parsing it in application code.
  5. Investigate a growing cache by correlating entry count, traced bytes, and process memory.

Keep this model

Object death makes storage reusable; it does not guarantee that every allocator layer returns pages to the OS. Pymalloc serves small CPython requests from blocks grouped into pools and arenas. Type free lists, native extensions, the platform allocator, and OS accounting add independent layers.

Use tracemalloc for traced allocation locations, graph tools for retaining references, allocator diagnostics for CPython internals, and process metrics for operational footprint. Agreement between layers strengthens a diagnosis. A high RSS number alone does not distinguish a leak from useful reuse or fragmentation.

Primary sources