Python memory in production
Article.
The mechanisms are covered one by one in the notes before this: where objects live (Stack and heap in Python), when they are freed (Python memory model — allocation, refcounting, GC), and how the allocator hands out and keeps memory (The allocator: heap, arenas, pools, blocks). This note puts them together around the question a senior is actually asked:
A worker’s memory is 2 GB and growing. Walk me through it. Every number
below was measured on Python 3.14.8 in a python:3.14-slim Linux container,
because what the operating system reports depends on Linux and glibc
behaviour that Windows and macOS do not share.
One object, end to end
x = {"id": 1}
│ pymalloc block > 16 KiB pool > 1 MiB arena
│ (over 512 bytes: glibc malloc instead)
▼
refcount > 0 ....... alive
refcount hits 0 .... freed at once, block to its pool
in a cycle ......... freed when the collector runs
▼
pool empty ......... reusable by any size class
arena empty ........ returned to the OS
malloc'd memory .... glibc decides; often keptTwo consequences drive everything below. Freed is not returned: memory goes back to the process’s allocators long before, if ever, it goes back to the OS. And Python can only report what Python allocated: native libraries allocate behind its back.
Which number are you looking at?
| Number | What it counts |
|---|---|
| RSS | pages in RAM, including shared ones |
USS / Private_Dirty |
pages only this process has written |
| PSS | RSS with shared pages split between sharers |
tracemalloc |
Python-level allocations only |
cgroup memory.current |
what the container limit is checked against |
The gap between them is information:
100k x 1 kB bytes objects: tracemalloc 99 MiB
then 200 MiB from C malloc: tracemalloc 99 MiB, RSS 325 MiBtracemalloc did not see the native 200 MiB at all. When RSS is far above
what tracemalloc reports, the memory is in a C extension (NumPy, a database
driver, an image library), in the allocator’s free lists, or in shared pages
counted per process. For prefork servers the right per-worker number is USS
or PSS, not RSS, which counts shared pages in every worker.
Four reasons the number is high
| Shape over time | Usual cause |
|---|---|
| unbounded growth | a leak: something keeps references |
| plateau, never falls | peak usage plus fragmentation |
unseen by tracemalloc |
native memory or allocator overhead |
| growth after fork | copy-on-write being broken |
A leak: references kept by something
class Report:
@functools.lru_cache(maxsize=None) # the cache key includes self
def total(self):
return sum(self.rows)Two thousand Report objects, each discarded after use: all 2,000 were still
alive. lru_cache on a method keys on self, so the cache holds every
instance forever. Two tracemalloc snapshots, diffed, point at the line that
allocated what was kept:
tracemalloc.start(10)
before = tracemalloc.take_snapshot()
... # run the suspect workload
after = tracemalloc.take_snapshot()
for stat in after.compare_to(before, "lineno")[:5]:
print(stat)
# +23109 KiB mem.py:66 +490000 blocks <- self.rows = list(range(n))The diff shows what was allocated; to find who holds it, take one
object and walk gc.get_referrers() or use objgraph.show_backrefs(). The
usual holders: module-level dicts and caches, lru_cache on methods,
registries of callbacks, stored exceptions (their traceback keeps every
frame’s locals), and closures
(Memory profiling — tracemalloc, memray, and what holds memory).
A plateau: freed but not returned
300k x 600-byte objects (> 512 B, so glibc malloc):
RSS 122 -> 203 MiB
98% freed, 1 in 50 kept: 201 MiB
malloc_trim(0): 41 MiBPython freed the objects; glibc kept the memory in its heap, ready for the
next allocation, because a few survivors pinned the top of it.
malloc_trim(0) (via ctypes) asked glibc to return what it could. Small
objects behave the same through pymalloc arenas, where one survivor keeps a
1 MiB arena (The allocator: heap, arenas, pools, blocks).
This is not a leak: the memory is reused by the next request. The process size is set by its peak, so the fix is lowering the peak (stream instead of loading everything), not chasing the plateau.
Allocator overhead: glibc arenas per thread
glibc gives threads their own malloc arenas to avoid lock contention, up to 8 per CPU core on 64-bit. Each keeps its own free memory:
16 threads, each churning 2 MB of 4 kB objects:
default: RSS 187 -> 209 MiB
MALLOC_ARENA_MAX=2: RSS 187 -> 187 MiBSmall here; in thread-heavy services on many-core machines it is often
hundreds of megabytes, and MALLOC_ARENA_MAX=2 in the environment, or a
different allocator (jemalloc via LD_PRELOAD), is the standard fix.
Copy-on-write lost after fork
gunicorn, uWSGI and Celery’s prefork pool load the application once and fork workers that share the parent’s pages until someone writes to them. In CPython, reading an object writes to it: the refcount changes. Child private memory after fork, with 300,000 small dicts inherited:
| Child does | Private memory |
|---|---|
| nothing | 0 MiB |
| reads every object | 46 MiB |
gc.collect() |
40 MiB |
gc.collect() after freeze |
0 MiB |
Reading copied 46 MiB per worker; with 16 workers that is 736 MiB of memory
that was meant to be shared. gc.freeze() before forking moves everything to
a permanent generation the collector never touches, so collections stop
writing to inherited objects.
It cannot stop refcount writes on objects the worker actually uses; immortal objects (3.12+) only cover built-in singletons and interned strings (Python memory model — allocation, refcounting, GC).
Containers: the limit Python cannot see
docker run --memory=128m: cgroup memory.max 128 MiB
allocated 50 MiB ... allocated 100 MiB ...
exit code: 137No MemoryError, no traceback, no log line from Python: the kernel’s OOM
killer sent SIGKILL when the cgroup hit its limit, and 137 is 128 + 9.
Python does not read the cgroup limit, and os.cpu_count() reported all 12
host CPUs inside the container too. Consequences:
- Size workers from the limit: workers × (baseline + per-request peak) must fit, with headroom for the allocator plateau.
- Recycle workers where memory creeps: gunicorn
--max-requestswith--max-requests-jitter, Celery--max-tasks-per-child,max_tasks_per_childinProcessPoolExecutor(3.11). This contains slow leaks and native fragmentation without finding them. - Alert before the kill: export the worker’s RSS or the cgroup’s
memory.currentand alert at 80%; a 137 in the logs means you were too late.
Making objects smaller
100k instances, two attributes:
regular class 128 B each
__slots__ 88 B each__slots__ removed the per-instance dictionary. CPython has stored instance
attributes inline since 3.11, so that dictionary was already smaller than it
used to be, and the gain is about 30%, not the 50-60% older articles quote.
Bigger wins usually come from
not materialising data at all: generators and iterator() instead of lists,
array/NumPy instead of lists of numbers (a Python int is 28 bytes plus an
8-byte pointer; an int64 array slot is 8), and streaming responses instead
of building them in memory.
The playbook
- Read the shape over hours: steady growth, plateau, or growth after each fork.
- Compare RSS with
tracemalloc. Close means Python objects; far apart means native memory or allocator overhead. - For growth, diff snapshots with
tracemallocover a repeated workload, then find the holder withgc.get_referrersorobjgraph. - For native memory, use
memray(memray run --native), which follows allocations into C extensions, orpy-spy dumpfor a live process. - For a plateau, lower the peak: stream, paginate, chunk. Try
malloc_trimorMALLOC_ARENA_MAXto confirm it is the allocator. - For prefork, measure USS per worker;
gc.freeze()before fork. - Contain what you cannot fix: worker recycling and a memory alert.
The trade not taken
Recycling workers every N requests hides almost any memory problem, leak or fragmentation, at the cost of a cold start per recycle and never learning the cause; it is the right first response and the wrong final one.
Replacing the allocator (jemalloc, mimalloc, which the free-threaded build already uses
internally) often cuts fragmentation and arena overhead without code
changes, but it is another moving part in the image. Rewriting hot data
structures in NumPy or Rust gives the largest reductions and costs the most.
Measure which kind of growth you have before choosing.
Related
Interview angle 6
- “A worker is at 2 GB and growing. Walk me through it.” - look at the
shape: unbounded growth is a leak, a plateau is peak plus fragmentation,
growth after fork is copy-on-write. Compare RSS with
tracemalloc: far apart means native memory. Then difftracemallocsnapshots and find the holder.- Follow-up: “
tracemallocsays 99 MiB, RSS says 325. Where is the rest?” - native allocations Python cannot see (200 MiB from C here), the allocator’s kept memory, or shared pages. Usememray --native.
- Follow-up: “
- “We freed the objects. Why didn’t memory go down?” - freed memory goes
back to pymalloc or glibc, not the OS. 98% freed left RSS at 201 MiB;
malloc_trim(0)brought it to 41. Lower the peak instead. - “Why does each gunicorn worker use more memory over time when they share
the app?” - reading an object writes its refcount, and a collection
writes GC headers, so shared pages get copied: 46 MiB per worker from
reading alone.
gc.freeze()before fork stops the collector’s part. - “How does a memory leak happen with a garbage collector?” - something
still references the objects: here
lru_cacheon a method kept all 2,000 instances. The collector frees only what is unreachable. - “What happens when a container hits its memory limit?” - the kernel
kills the process with
SIGKILL, exit 137, with no Python error. Size workers from the limit, recycle them, alert before it happens.- Follow-up: “Would
MALLOC_ARENA_MAXorjemallochelp?” - only if the growth is allocator overhead; here 16 threads added 22 MiB by default and none withMALLOC_ARENA_MAX=2. They do nothing for a leak.
- Follow-up: “Would
- “Do
__slots__matter?” - about 30% per instance here (128 to 88 bytes); worth it for millions of small objects, not for a few thousand.