backend / python core / performance / 02_memory_profiling.md

Memory profiling — tracemalloc, memory_profiler, objgraph

3 min read source

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.mddict.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?” → tracemalloc snapshots 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. Use pympler.asizeof for deep size.)