Python hides most manual memory management, which is usually a good thing. You create an object, keep it for as long as it is useful, and let the runtime handle the rest. Trouble starts when an application retains far more data than expected or when “freeing” objects does not reduce the process size shown by the operating system.

The first distinction matters: Python is a language; CPython is its most widely used implementation. Reference counting, PyObject, obmalloc, and the details of the small-object allocator are CPython behavior. PyPy, Jython, and other implementations make different choices. This article focuses on current CPython and points out the parts that should not be treated as language guarantees.

Object Lifetime in CPython

CPython combines immediate reference counting with a cyclic garbage collector.

Reference counting

Each CPython object carries a reference count. Creating another strong reference generally increases it; removing one decreases it. When the count reaches zero, CPython can usually finalize the object immediately.

import sys
items = []
print(sys.getrefcount(items))

getrefcount() temporarily receives another reference as its argument, so the reported number is one higher than it would otherwise be at that moment. Reference counts are implementation details and can also be affected by optimizations in newer CPython releases. They are useful for investigation, not as application logic.

At the C level, ordinary CPython objects begin with fields represented by PyObject, including type information and reference-counting state. Extension authors use Py_INCREF, Py_DECREF, and related APIs rather than modifying those details casually.

Cycles

Reference counting alone cannot reclaim a cycle:

first = []
second = [first]
first.append(second)
del first, second

The two lists refer to each other, so neither reference count falls directly to zero. CPython’s cyclic collector tracks container objects that can participate in cycles and periodically looks for groups that are no longer reachable from outside the group.

This is not best described as a conventional root-tracing collector for every Python object. CPython uses its knowledge of references among tracked container objects to identify unreachable cycles alongside normal reference counting. The algorithm and generation layout have changed across releases, so version-specific tuning should be based on the documentation for the Python version in production.

import gc
print(gc.get_threshold())
collected = gc.collect()
print(f"Collected {collected} unreachable objects")

Manual collection is rarely a first-line optimization. It can be useful in controlled batch workloads or while debugging, but calling it frequently may add latency without fixing the references that keep live data alive.

Allocation Layers

CPython exposes three allocator domains to C extensions:

  • the raw domain for general system-level allocation;
  • the memory domain for memory managed within the Python runtime;
  • the object domain for Python objects.

On a normal build, CPython’s pymalloc allocator handles many requests up to 512 bytes in the memory and object domains. It groups equal-sized blocks into pools and pools into arenas. Current CPython documentation specifies 1 MiB arenas on 64-bit platforms and 256 KiB arenas on 32-bit platforms.

That description needs a build qualifier. Free-threaded CPython builds use mimalloc for the memory and object domains rather than pymalloc. Allocator behavior is therefore something to record alongside the Python version, platform, and build configuration—not a permanent property of every interpreter carrying the CPython name.

Some built-in types also keep type-specific free lists. Those caches and pymalloc pools are related optimizations, but they are not the same mechanism.

Why RSS May Stay High

Deleting an object makes its storage available to the Python allocator when nothing else references it. That does not mean the allocator immediately returns the containing arena to the operating system. A mostly empty arena may still contain a few live blocks, and retained arenas can satisfy later allocations quickly.

This explains a common observation:

  1. a program allocates a large number of small objects;
  2. most of them are deleted;
  3. Python can reuse much of that space;
  4. the resident set size reported by the OS does not fall by the same amount.

That behavior is not automatically a leak. A leak or retention problem means memory continues to grow because objects, native allocations, caches, or allocator fragmentation remain in use when the program no longer needs them.

Measure Before Optimizing

tracemalloc records many allocations made through Python’s memory allocators and can compare snapshots:

import tracemalloc
tracemalloc.start()
before = tracemalloc.take_snapshot()
# Run the operation being investigated here.
after = tracemalloc.take_snapshot()
for stat in after.compare_to(before, "lineno")[:10]:
print(stat)

It does not see every byte allocated by every native library. Compare its results with process-level measurements when NumPy, image libraries, database drivers, or custom extensions are involved.

Other useful tools include:

  • gc.get_referrers() for careful interactive investigation;
  • objgraph for visualizing object growth;
  • memory_profiler or Memray for workload-level profiling;
  • operating-system tools for resident memory, mappings, and child processes.

sys.getsizeof() reports the shallow size of one object. It does not recursively include every object reachable from it, so adding those numbers without understanding shared references can be misleading.

An Experiment: Python Bytes Versus Process RSS

The following Linux experiment records two views of the same workload:

  • tracemalloc reports currently traced Python allocations;
  • /proc/self/statm reports resident pages for the whole process.
import gc
import os
import tracemalloc
def rss_mib() -> float:
with open("/proc/self/statm", encoding="ascii") as handle:
resident_pages = int(handle.read().split()[1])
return resident_pages * os.sysconf("SC_PAGE_SIZE") / (1024 ** 2)
def report(label: str) -> None:
current, peak = tracemalloc.get_traced_memory()
print(
f"{label:>12} | "
f"traced={current / 2**20:8.1f} MiB | "
f"peak={peak / 2**20:8.1f} MiB | "
f"rss={rss_mib():8.1f} MiB"
)
tracemalloc.start()
report("start")
payload = [bytearray(128) for _ in range(400_000)]
report("allocated")
del payload
gc.collect()
report("released")

Run it as a fresh process several times. The exact numbers depend on CPython version, allocator, libc, and operating system, so the output is not a benchmark to copy into another machine’s capacity plan.

The useful comparison is directional. After del and collection, traced current memory should fall when no live references remain. RSS may fall by less because arenas, native allocator pages, interpreter state, and fragmentation remain resident. That gap is evidence to investigate; it is not by itself proof of a leak.

Two follow-up runs can isolate allocator effects:

Terminal window
python memory_probe.py
PYTHONMALLOC=malloc python memory_probe.py
PYTHONMALLOCSTATS=1 python memory_probe.py 2> allocator-stats.txt

PYTHONMALLOC=malloc changes the allocator and may change performance as well as RSS. It is a diagnostic comparison, not a default production fix. PYTHONMALLOCSTATS is implementation-specific and verbose, but it can show arena activity that tracemalloc does not explain.

Reading the result without guessing

ObservationMore likely next question
traced current and RSS both grow with each requestWhich Python object types or tracebacks retain memory?
traced current falls but RSS remains on a plateauIs reusable allocator retention acceptable under the workload?
RSS grows while tracemalloc stays flatIs a native extension, mapped file, thread stack, or child process responsible?
memory drops only after a worker exitsWould bounded worker recycling contain fragmentation while the root cause is investigated?
growth follows one tenant or input shapeIs an unbounded cache, queue, or data-dependent native allocation involved?

Peak RSS is not the same as current RSS. Tools based on getrusage() often report a high-water mark that cannot decrease, so use the metric name carefully before concluding that memory was not released.

A Memory Investigation in Production

Start with a timeline, not a heap dump. Align memory growth with request rate, input size, deployments, cache occupancy, worker count, and error rate. Then narrow the layer:

  1. Confirm scope. Is growth in one worker, every worker, a sidecar, or a child process?
  2. Measure current and peak values separately. Include RSS, virtual memory, cgroup usage, and swap where relevant.
  3. Compare tracemalloc snapshots. Group by traceback, not only by the final allocation line.
  4. Count live object types. A growing count suggests retention; a stable count with growing RSS points elsewhere.
  5. Inspect native allocations. NumPy, PyTorch, database drivers, image codecs, and custom extensions may allocate outside the traced Python heap.
  6. Reproduce in a fresh process. Allocator history matters, so a minimal loop is more informative than one snapshot from a week-old worker.
  7. Change one variable. Bound a cache, disable one extension path, change the allocator, or shorten worker lifetime. Treat the difference as evidence, not a cure.

In containerized services, compare process RSS with the cgroup’s memory counters. The limit is enforced at the cgroup boundary, which may include sibling processes and page cache that an application-level profiler does not show.

Practical Ways to Use Less Memory

Stream instead of collecting everything

Generators let a program process values one at a time:

def error_lines(path):
with open(path, encoding="utf-8") as handle:
for line in handle:
if "ERROR" in line:
yield line.rstrip("\n")
for line in error_lines("application.log"):
print(line)

This helps only if later code also consumes the iterator incrementally. Wrapping it immediately in list() brings the whole result back into memory.

Drop references held by containers and caches

Long-lived dictionaries, queues, callbacks, closures, and global caches are common retention sources. Bound cache size, expire entries when appropriate, and remove completed work from queues. A local variable going out of scope will not help if another container still owns the object.

Weak references are useful when a mapping should not keep an object alive:

import weakref
class Document:
pass
document = Document()
reference = weakref.ref(document)
del document
print(reference()) # Usually None on CPython at this point

The exact moment of collection is not a portable Python-language promise, so code should not depend on immediate finalization.

Choose a suitable representation

A Python integer or dictionary entry carries much more overhead than a packed numeric value. Arrays from the standard library or NumPy can be far more compact for homogeneous data. Pandas can help with tabular work, but it is not automatically smaller than every native structure; dtypes, indexes, strings, and copies determine the result.

Classes with many instances may benefit from dataclasses(slots=True) or __slots__:

from dataclasses import dataclass
@dataclass(slots=True)
class Point:
x: float
y: float

Slots remove the usual per-instance attribute dictionary unless one is requested. They change class behavior and inheritance rules, so measure the gain before applying them everywhere.

Avoid copies with the buffer protocol

memoryview exposes the buffer of an object such as bytes, bytearray, or an array without copying it:

data = bytearray(b"Hello, World!")
view = memoryview(data)
view[7:12] = b"bytes" # Equal-length replacement; the buffer cannot be resized
print(data) # bytearray(b'Hello, bytes!')

An active view can prevent the exporting object from being resized. Zero-copy code also extends the lifetime of the underlying buffer, which can retain more memory than expected if a tiny view points into a very large object.

Memory-map large files when access patterns suit it

mmap provides file-backed virtual memory and can avoid copying an entire file into a Python bytes object. It is useful for random access or sharing mapped pages, but it is not a guaranteed memory reduction. Page cache, access pattern, address-space use, and operating-system behavior still matter.

C Extensions Need Extra Care

Native extensions can allocate outside Python’s tracked heap, keep borrowed references too long, or release references incorrectly. They can also crash the process rather than raise a Python exception. Use the documented CPython memory and reference APIs, define ownership clearly, and test with tools suited to native memory when tracemalloc cannot explain growth.

Conclusion

Most Python programs do not need allocator tuning. They need clear object lifetimes, bounded collections, streaming where it fits, and measurements taken under a realistic workload. When implementation details do matter, name the implementation and version. “Python keeps memory” is too vague to diagnose; a snapshot that shows which objects or native allocations are growing gives you something you can actually fix.

References