Memory profiling — tracemalloc, memory_profiler, objgraph
Time profiling answers “what’s slow.” Memory profiling answers “where is the RAM going” and “why isn’t this getting freed?”
tracemalloc — stdlib, low overhead
Snapshots heap allocations and shows where each MB came from:
import tracemalloc
tracemalloc.start()
# ... run code ...
snapshot = tracemalloc.take_snapshot()
top = snapshot.statistics("lineno")
for stat in top[:10]:
print(stat)
# /app/parser.py:42: size=12.3 MiB, count=98432, average=131 B
# /app/main.py:11: size=4.1 MiB, count=10000, average=429 B
# ...
Compare two snapshots to find what grew:
tracemalloc.start()
snap_before = tracemalloc.take_snapshot()
do_work()
snap_after = tracemalloc.take_snapshot()
diff = snap_after.compare_to(snap_before, "lineno")
for stat in diff[:10]:
print(stat)
# /app/cache.py:88: size=+8.2 MiB, count=+1000
This is the canonical way to find leaks in long-running services.
memory_profiler — line-by-line
Decorate a function to see per-line memory usage:
# pip install memory-profiler
from memory_profiler import profile
@profile
def load_data():
a = [0] * 1_000_000
b = [0] * 9_000_000
del b
return a
Run with python -m memory_profiler script.py:
Line # Mem usage Increment Line
5 38.6 MiB 38.6 MiB @profile
6 38.6 MiB 0.0 MiB def load_data():
7 46.4 MiB 7.8 MiB a = [0] * 1_000_000
8 115.5 MiB 69.1 MiB b = [0] * 9_000_000
9 46.4 MiB -69.1 MiB del b
10 46.4 MiB 0.0 MiB return a
Slow (~100x overhead). Use on suspect functions, not whole programs.
objgraph — find what holds objects alive
When tracemalloc says “memory grew” but you don’t see why, objgraph traces backref chains:
import objgraph
objgraph.show_growth() # objects that increased since last call
objgraph.show_most_common_types(limit=10)
# dict 12345
# list 9876
# Article 5432
# Find what's holding an Article alive:
articles = objgraph.by_type("Article")
objgraph.show_backrefs([articles[0]], filename="refs.png")
The graph shows the chain from a root (module, frame, thread) to the leaked object.
Common Python memory issues
1. Module-level caches
# global cache → grows forever
_cache = {}
def get(k):
if k not in _cache:
_cache[k] = load(k)
return _cache[k]
Fix: bound it (functools.lru_cache(maxsize=N)) or use weakref.WeakValueDictionary.
2. Logging objects with references
logger.info("processing %s", big_object) # logger may hold reference
The default logging formats lazily but some handlers hold the LogRecord. Be careful in async code where buffers accumulate.
3. Closures keeping data alive
def make_callback(big_data):
def cb(event):
return process(event, big_data)
return cb
callbacks.append(make_callback(huge)) # `huge` lives as long as the callback
If cb doesn’t actually need big_data, refactor.
4. Large strings in tracebacks
Held exception context (__context__, __cause__, __traceback__) keeps frames alive. In production error logging, capture summary info and drop the exception object:
try:
work()
except Exception as e:
logger.exception("failed")
# don't store `e` in a long-lived structure
5. Default dict.get factory
See tricky_questions/16_dict_get_falsy_default.md — dict.get(k, []) builds a fresh list each call.
When to reach for what
| Symptom | Tool |
|---|---|
| “Memory grows over time in production” | tracemalloc (compare snapshots over time) |
| “This function uses too much memory” | memory_profiler |
| “I can’t figure out why X isn’t collected” | objgraph |
| “I want to know object sizes” | sys.getsizeof, pympler.asizeof |
Interview angle
- “How would you debug a Python service whose memory grows over hours?” →
tracemallocsnapshots at intervals, compare; suspect caches, accumulating logs, retained references. - “What does
sys.getsizeof([1, 2, 3])return?” (Size of the list object header + pointer array, not including pointed-to objects. Usepympler.asizeoffor deep size.)