Monitoring Python GC and Memory Usage
Memory metrics in Python are easy to collect and easy to misread: the number the container kills you for and the number your objects account for are not the same, and neither one shows the pauses that turn a memory problem into a latency problem. This page covers the three signals worth exporting and how to read them together. It builds on runtime and service metrics, part of the Python metrics and instrumentation section.
Prerequisites
pip install "prometheus-client>=0.20.0,<1.0.0"
export GC_METRICS_ENABLED=1
export TRACEMALLOC_FRAMES=10 # only when diagnosing
Implementation
Step 1 — Export the process metrics. prometheus_client registers the process collector automatically on Linux; the figure to alert on is resident memory.
process_resident_memory_bytes 4.02653184e+08
process_virtual_memory_bytes 1.2884902e+09
process_open_fds 143
Resident set size is what the cgroup limit compares against, so it is the alerting signal. Virtual memory is nearly meaningless for this purpose — a process can map far more address space than it will ever touch.
Step 2 — Time the collections. The default collector counts them; counts do not show pauses.
import gc
import time
from prometheus_client import Counter, Histogram
GC_PAUSE = Histogram(
"python_gc_pause_seconds", "Garbage collection pause duration", ["generation"],
buckets=(0.0005, 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5),
)
GC_COLLECTED = Counter("python_gc_collected_objects_total", "Objects freed", ["generation"])
GC_UNCOLLECTABLE = Counter("python_gc_uncollectable_total", "Objects not freed", ["generation"])
_started: dict[int, float] = {}
def _on_gc(phase: str, info: dict) -> None:
gen = str(info["generation"])
if phase == "start":
_started[info["generation"]] = time.perf_counter()
return
began = _started.pop(info["generation"], None)
if began is not None:
GC_PAUSE.labels(gen).observe(time.perf_counter() - began)
if info.get("collected"):
GC_COLLECTED.labels(gen).inc(info["collected"])
if info.get("uncollectable"):
GC_UNCOLLECTABLE.labels(gen).inc(info["uncollectable"])
gc.callbacks.append(_on_gc)
Keep the callback to timestamps and counters. It runs during collection, on whichever thread triggered it, and anything that allocates there is asking for a bad time.
Step 3 — Watch generation 2 separately. Generations 0 and 1 are frequent and short; generation 2 walks everything that has survived, and its pause scales with the size of the live object graph — which is exactly what grows when there is a leak.
# the pause that shows up as latency
histogram_quantile(0.99, sum(rate(python_gc_pause_seconds_bucket{generation="2"}[5m])) by (le))
Step 4 — Freeze the startup graph before forking. gc.freeze() moves everything currently tracked into a permanent generation the collector never rescans.
# gunicorn.conf.py
import gc
def when_ready(server):
gc.collect() # tidy up first
gc.freeze() # imports and startup objects are never rescanned again
def post_fork(server, worker):
gc.enable()
Two benefits: generation-2 pauses stop re-walking the interpreter's startup graph, and the pages holding those objects are not touched by the collector, so they stay copy-on-write shared between prefork workers instead of being duplicated.
Step 5 — Use tracemalloc only to diagnose. It roughly doubles allocation cost, so bound it in time and scope.
import tracemalloc
from fastapi import APIRouter
router = APIRouter(prefix="/admin/memory")
_baseline = None
@router.post("/start")
async def start():
global _baseline
tracemalloc.start(10)
_baseline = tracemalloc.take_snapshot()
return {"tracing": True}
@router.get("/top")
async def top():
snapshot = tracemalloc.take_snapshot()
stats = snapshot.compare_to(_baseline, "lineno")[:10]
return {"top": [str(s) for s in stats]}
@router.post("/stop")
async def stop():
tracemalloc.stop()
return {"tracing": False}
min_over_time the query worth alerting on.Configuration options
| Option | Type | Default | Recommended |
|---|---|---|---|
process_resident_memory_bytes |
gauge | auto | the alerting signal |
python_gc_pause_seconds |
histogram | absent | add via gc.callbacks |
| Buckets | tuple | — | 0.0005 s to 0.5 s |
generation label |
str | — | three values only |
gc.freeze() |
— | not called | after imports, before fork |
gc.set_threshold |
tuple | (700, 10, 10) |
raise gen-2 only with evidence |
tracemalloc |
— | off | on, briefly, one replica |
Verification
curl -s localhost:9000/metrics | grep -E 'process_resident|python_gc'
Expected Output:
process_resident_memory_bytes 4.02653184e+08
python_gc_collections_total{generation="0"} 41822.0
python_gc_collections_total{generation="2"} 61.0
python_gc_pause_seconds_bucket{generation="2",le="0.05"} 58.0
python_gc_pause_seconds_bucket{generation="2",le="0.25"} 61.0
python_gc_pause_seconds_sum{generation="2"} 3.94
python_gc_uncollectable_total{generation="2"} 0.0
Three readings to check. Generation-2 collections are rare — 61 against 41 822 generation-0 passes is normal. Three of those exceeded 50 ms, which is worth knowing and is invisible in any request metric. And uncollectable is zero; a rising value there means a reference cycle involving objects the collector will not free, which is a genuine leak rather than ordinary growth.
Then confirm the leak-versus-growth shape over a longer window:
# does memory fall during the quiet period?
min_over_time(process_resident_memory_bytes[24h])
A service without a leak gives some memory back overnight. One with a leak keeps its peak, and the minimum rises day over day.
Common mistakes
Alerting on Python object counts
Error signature: the alert never fires and the container is killed anyway.
Root cause: object counts do not track resident memory, because the allocator does not return freed arenas promptly.
Remediation: alert on process_resident_memory_bytes against the container limit, and use object counts for diagnosis only.
Disabling the collector to remove pauses
Error signature: pauses disappear and memory rises without bound.
Root cause: cycle collection is what frees reference cycles, and ORM and framework object graphs are full of them.
Remediation: raise the generation-2 threshold if pauses are genuinely the problem, and call gc.freeze() after startup — both keep collection working.
tracemalloc left enabled
Error signature: allocation-heavy endpoints slow by tens of percent, and memory rises by the tracing overhead itself. Root cause: a diagnostic session that was started and never stopped. Remediation: expose start and stop as separate operations, and add a timer that stops tracing automatically.
Finding what is actually leaking
Metrics tell you memory is climbing; they do not tell you what is holding it. Three techniques, in the order they are worth trying, because each is cheaper than the next.
Object counts by type. gc.get_objects() walked once and counted by type name gives a distribution, and comparing two snapshots taken twenty minutes apart shows which type grew. It is coarse — a leak of dictionaries tells you very little — and it is often enough, because the growing type is frequently domain-specific and names the subsystem immediately.
import gc
from collections import Counter
def type_histogram(top: int = 15) -> list[tuple[str, int]]:
counts = Counter(type(obj).__name__ for obj in gc.get_objects())
return counts.most_common(top)
Run it behind an admin endpoint, not on a timer: walking every object is expensive and pauses the process for the duration.
Allocation traces. tracemalloc attributes memory to the source line that allocated it, which is the answer when the type histogram says "dict" and you need to know which dict. Take a baseline, wait, take a second snapshot, and compare — the top entries by growth are the allocation sites responsible.
Referrer chains. When the allocation site is known and the reason the objects survive is not, gc.get_referrers() on a sample of the leaking objects shows what is holding them. This is the most laborious of the three and the one that finds the genuinely puzzling cases: a cache without an eviction policy, a list that accumulates for a report nobody generates, a closure capturing a request.
| Technique | Cost | Answers |
|---|---|---|
| Type histogram | a pause per run | which type is growing |
tracemalloc comparison |
~2× allocation cost while on | which line allocated it |
| Referrer chains | manual, slow | why it is still alive |
| Heap dump and offline analysis | heavy, needs tooling | everything, eventually |
The leaks that are not bugs
Two very common patterns look exactly like leaks in the metrics and are working as designed, and recognising them saves a day of investigation.
Caches without bounds. An lru_cache with maxsize=None, a module-level dictionary used as a memo, a connection pool that grows to its high-water mark and stays there. Each of these grows to the size of its key space and then plateaus — which is fine when the key space is small, and indistinguishable from a leak when the key is something like a customer ID. The distinction is whether the plateau exists at all, which is why the daily-minimum reading matters more than the slope.
Fragmentation. A workload that allocates a large number of objects, frees them, and repeats can leave the allocator holding arenas it cannot return because a few live objects are scattered across them. Resident memory stays high while Python-level object counts are flat, which looks like a leak in the outer metric and is invisible in the inner one. There is no fix in application code beyond allocating differently; the practical response is to size the container for the high-water mark.
Choosing what to alert on
Given all of the above, the alerting policy that works is narrower than it first appears. Alert on resident memory approaching the container limit, because that is the failure. Alert on the twenty-four-hour minimum rising over several days, because that is the leak signature and it fires early enough to act on. Do not alert on generation-2 pause duration, object counts, or the slope of resident memory within a day — all three move for ordinary reasons and produce pages nobody can act on.
The GC pause metric earns its place on the dashboard rather than in the alert policy: it is what you look at when latency is spiking and you need to know whether collection explains it, which is a question asked during an incident rather than one that should start one.
Related
- Runtime and service metrics — the parent guide: the four families and what each answers.
- Measuring asyncio event loop lag — the other signal that explains latency the request metrics cannot.
- Choosing between Counter, Gauge, Histogram and Summary — why pauses are a histogram and memory is a gauge.
- Prometheus client instrumentation in Python — the default collectors and their multiprocess behaviour.
- Buffering log records with MemoryHandler — one Python-side structure that quietly pins memory.
Frequently Asked Questions
Why does resident memory not fall after objects are freed?
Because CPython's allocator holds freed arenas for reuse rather than returning them to the operating system immediately, and fragmentation can prevent a whole arena from ever becoming free. The consequence is that resident set size is a high-water mark more than a current-usage figure, which is fine for capacity purposes and misleading if you expect it to track object counts.
Should I disable the garbage collector?
Almost never in a long-running service. Disabling gc removes cycle collection, so any reference cycle — which includes many ordinary framework and ORM object graphs — leaks permanently. Tuning the thresholds so generation-2 runs less often is the useful version of that idea, and freezing long-lived startup objects with gc.freeze before forking is the version with the clearest payoff.
What does gc.freeze actually do?
It moves everything currently tracked into a permanent generation that collections never examine. Called after imports and before forking workers, it means the collector stops re-walking the interpreter's startup object graph on every generation-2 pass, which both shortens those pauses and avoids touching pages that would otherwise be copy-on-write shared between prefork workers.
How do I tell a leak from ordinary growth?
By the shape over a longer window than one deploy cycle. Ordinary growth rises and plateaus as caches and pools fill. A leak rises without plateauing, and the ratio of resident memory to request rate rises with it. The clearest confirmation is that memory does not fall during a quiet period — a service with a leak keeps its overnight peak, a service without one gives some back.
Is tracemalloc safe to run in production?
It works, and it roughly doubles allocation cost and adds memory of its own, so it is a diagnostic rather than a monitoring tool. The usual approach is an admin endpoint that starts it, takes a snapshot after a few minutes, compares against a baseline snapshot and stops it — bounded in time, on one replica.