Python Module 17 – Performance, profiling and debugging in depth
Finding memory problems with tracemalloc
Find what makes a Python program use more and more memory: take tracemalloc snapshots, compare them line by line, and tell shallow sizes from real ones.
What you will learn
- Measure allocations with tracemalloc snapshots, statistics and peaks
- Find the line and the reference behind memory that keeps growing
- Explain why sys.getsizeof() undercounts containers, with a measurement
- Save memory with bounded caches, generators, slots and arrays
Before you start
What you’ll learn · Before you start
What you will learn
- Measure allocations with tracemalloc snapshots, statistics and peaks
- Find the line and the reference behind memory that keeps growing
- Explain why sys.getsizeof() undercounts containers, with a measurement
- Save memory with bounded caches, generators, slots and arrays
Before you start
On this page
After 5,000 requests from new users, a small service holds 3,621 KiB more than before, and nothing in it will ever
free that memory. The standard library’s tracemalloc module finds such growth: it records every block of memory
that Python allocates, with the file and line that asked for it. Start it with tracemalloc.start(), take a
snapshot with tracemalloc.take_snapshot(), and snapshot.statistics("lineno") lists the lines that hold the most
memory.
A second snapshot taken later, compared with compare_to(), shows what grew in between, and get_traced_memory()
returns the current and the peak size. In Python, memory that seems to leak is nearly always memory that something
still refers to: a cache that never forgets, a module-level list that only grows, or a
reference cycle: Objects that refer to each other, or one object that refers to itself, so that their reference counts never reach 0 on their own. CPython's garbage collector finds such groups when nothing else can reach them, and frees them. More in “__slots__, weak references and object lifetime” waiting for the
garbage collector: The part of CPython that looks for reference cycles that nothing can reach any more and frees them. It runs by itself from time to time, and gc.collect() runs it at once. More in “__slots__, weak references and object lifetime”. Below, two snapshots find that service’s growing cache, and a
measurement shows why sys.getsizeof() alone gives the wrong answer.
Two snapshots show which line holds the new memory
load_profile() keeps every profile it reads in a dictionary at module level, so that the next request for the same
user is fast. The program serves 1,000 requests to warm up, takes a snapshot, serves 5,000 more from new users and
takes another. filter_traces() leaves out tracemalloc’s own allocations, and compare_to(before, "lineno") sorts the
differences by size:
import linecache
import tracemalloc
_profiles = {} # module level: lives as long as the program
def load_profile(user_id):
"""Pretend to read a user's profile from a database, and keep it for next time."""
if user_id not in _profiles:
_profiles[user_id] = {"id": user_id, "name": f"user-{user_id}", "history": list(range(50))}
return _profiles[user_id]
def handle_request(user_id):
return len(load_profile(user_id)["history"])
tracemalloc.start()
# Warm up first: the first requests may allocate things that stay, such as caches inside Python itself.
for user_id in range(1_000):
handle_request(user_id)
before = tracemalloc.take_snapshot()
# 5,000 more requests, each from a new user.
for user_id in range(1_000, 6_000):
handle_request(user_id)
after = tracemalloc.take_snapshot()
ignore = [tracemalloc.Filter(False, tracemalloc.__file__)] # leave out tracemalloc's own allocations
growth = after.filter_traces(ignore).compare_to(before.filter_traces(ignore), "lineno")
print(f"traced memory now: {tracemalloc.get_traced_memory()[0] / 1024:,.0f} KiB")
print("largest growth between the two snapshots:")
for stat in growth[:2]:
frame = stat.traceback[0]
source = linecache.getline(frame.filename, frame.lineno).strip()
print(f" line {frame.lineno}: +{stat.size_diff / 1024:,.0f} KiB in {stat.count_diff:+,} blocks {source}") Output
traced memory now: 4,521 KiB
largest growth between the two snapshots:
line 10: +3,621 KiB in +25,000 blocks _profiles[user_id] = {"id": user_id, "name": f"user-{user_id}", "history": list(range(50))}
line 24: +156 KiB in +5,000 blocks for user_id in range(1_000, 6_000):
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 cache_growth.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Version note
The browser runner is a 32-bit build, where a reference takes 4 bytes instead of 8, so every size comes out about half as large there. The lines and the numbers of blocks are the same.
In your browser, Pyodide 314.0.7 (CPython 3.14.2) prints:
traced memory now: 2,389 KiB
largest growth between the two snapshots:
line 10: +1,927 KiB in +25,000 blocks _profiles[user_id] = {"id": user_id, "name": f"user-{user_id}", "history": list(range(50))}
line 24: +78 KiB in +5,000 blocks for user_id in range(1_000, 6_000):
Step through the measurement, one stage at a time:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- Start tracing. tracemalloc.start() makes Python record every block of memory it allocates from now on, with the file and the line that asked for it.
- Warm up. 1,000 requests run first. Memory that a program allocates once and keeps on purpose, such as caches inside Python itself, is allocated now, before the first snapshot.
- Snapshot before. The first snapshot copies the traces of every block that is alive at this moment. Memory that was allocated and freed again is not in it.
- The work. 5,000 more requests come from new users, and load_profile() keeps each new profile in the module-level dict _profiles.
- Snapshot after. The second snapshot copies the traces again. The traced memory is now 4,521 KiB.
- Compare. compare_to() subtracts the first snapshot from the second, line by line, after filter_traces() has left out the blocks of tracemalloc itself.
- The culprit. Line 10, the line that stores a profile in _profiles, holds 3,621 KiB more than before, in 25,000 more blocks, five for each new profile. Nothing has freed them.
- The keys. Line 24 holds 156 KiB more, in 5,000 blocks, one int object for each new user id, kept alive as keys of the same dict.
The first line of the difference names the culprit: the line that stores a profile in _profiles holds 3,621 KiB more
than before, in 25,000 more blocks, five per new profile: the dictionary and its table of entries, the name string,
and the history list with the array of its items. The second line is the loop itself: each user id is a new int
object, kept alive because the dictionary keeps it as a key. A snapshot holds only memory that is alive when it is
taken, so growth between two snapshots is memory that nothing has freed. The next question is what keeps it: here,
the module-level name _profiles.
Checkpoint
Check: Comparing two snapshots
Memory stays alive while something refers to it
CPython frees an object as soon as nothing refers to it any more, when its reference count: The number of strong references to an object, which CPython keeps in the object itself. When it falls to 0, CPython frees the object at once. More in “__slots__, weak references and object lifetime” falls to zero, so memory stays in use for one of two reasons: something reachable still refers to it, or it sits in a reference cycle that the garbage collector has not freed yet. The lesson on object lifetime showed both; this is how they look in a running program:
What keeps memory alive in a Python program
Text description of the diagram
1. The module-level name _profiles refers to a dict, and the dict refers to every profile ever loaded. Everything reachable from a module-level name lives as long as the program, so this dict grows with every new user. 2. A list that a function builds and never stores anywhere is referred to only by a local variable. When the call returns, the local name goes, nothing refers to the list any more, and reference counting frees it at once. 3. Two objects that refer to each other form a reference cycle. Their reference counts never reach zero, so they stay in memory after the program has dropped them, until the garbage collector finds and frees the cycle.
Everything reachable from a module-level name, a class attribute or a long-lived object lives as long as they do. A cache, a registry of callbacks or a list of past events that is only added to grows with the work done, which is why a long-running service shows such growth and a short script does not.
Key idea
Memory that keeps growing is held by something reachable, such as a module-level cache, or by a cycle that waits for the garbage collector.
Checkpoint
Check: What keeps memory alive
sys.getsizeof() measures one object
sys.getsizeof() returns the size of one object, without the objects it refers to, so for a container it measures
the container alone. tracemalloc counts every allocation, so the two disagree whenever a container holds objects of
its own:
import sys
import tracemalloc
from array import array
N = 10**6
def traced(build):
"""Build an object while tracemalloc runs; return it with the bytes it took."""
tracemalloc.start()
before = tracemalloc.get_traced_memory()[0]
obj = build()
after = tracemalloc.get_traced_memory()[0]
tracemalloc.stop()
return obj, after - before
numbers, list_bytes = traced(lambda: list(range(N)))
packed, array_bytes = traced(lambda: array("i", range(N)))
print(f"{64 if sys.maxsize > 2**32 else 32}-bit build, {N:,} integers")
print(f"list: getsizeof {sys.getsizeof(numbers):>10,} B traced {list_bytes:>11,} B")
print(f"array('i'): getsizeof {sys.getsizeof(packed):>10,} B traced {array_bytes:>11,} B")
print(f"one int object: {sys.getsizeof(N - 1)} B, one reference: {8 if sys.maxsize > 2**32 else 4} B, one array item: {packed.itemsize} B") Output
64-bit build, 1,000,000 integers
list: getsizeof 8,000,056 B traced 39,991,384 B
array('i'): getsizeof 4,091,948 B traced 4,091,980 B
one int object: 28 B, one reference: 8 B, one array item: 4 B
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 list_vs_array.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Version note
The browser runner is a 32-bit build: a reference takes 4 bytes there and a small int 16, so the list and its integers take about half the memory of the 64-bit run, while the array of 4-byte items stays the same size.
In your browser, Pyodide 314.0.7 (CPython 3.14.2) prints:
32-bit build, 1,000,000 integers
list: getsizeof 4,000,028 B traced 19,994,748 B
array('i'): getsizeof 4,091,908 B traced 4,091,924 B
one int object: 16 B, one reference: 4 B, one array item: 4 B
On the 64-bit build, getsizeof() reported 8 MB for the list: one 8-byte reference per item. Building it took about
40 MB, because each of the million integers is an object of its own, which tracemalloc counted at 32 bytes. An
array("i") stores the numbers themselves, 4 bytes each, so both measurements agree for it, at a tenth of the list’s
real size. Step through what each measurement looked at:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The list alone. getsizeof() looks at the list object only, its header and one 8-byte reference per item, and reports 8,000,056 bytes.
- What it points to. Every reference points to an int object of its own. getsizeof() of the list never follows the references, so none of these objects is in its number.
- Everything built. tracemalloc counted every block that building the list allocated, 39,991,384 bytes, the list itself plus about 32 bytes for each int, 4 more than the 28 that getsizeof() reports for one.
- The array. array('i') stores the numbers themselves, 4 bytes each, in its own buffer, so there is nothing more to follow. getsizeof() says 4,091,948 bytes and tracemalloc 4,091,980.
- The difference. For the same million numbers the list and its integers take almost ten times the memory of the array, and getsizeof() saw only a fifth of the list's real cost.
Checkpoint
Check: Shallow sizes
Each memory question has its own tool
- tracemalloc answers where memory was allocated and what grew between two moments.
- sys.getsizeof() gives the size of one object without what it refers to.
- gc.get_referrers() lists the objects that refer to a given object, to find who keeps it alive.
- gc.collect() runs the garbage collector at once and returns how many unreachable objects it found.
- functools.lru_cache(maxsize=…) keeps a fixed number of recent results instead of every result.
- Generators produce one item at a time, so a generator pipeline: A chain of generator functions in which each stage takes the stage before it as its input and yields its results to the next, so items flow through one at a time and memory stays flat whatever the size of the input. More in “Generator pipelines, yield from and send” holds one item instead of the whole list.
__slots__and arrays store many small records or numbers with less memory per item.
| Criterion | tracemalloc | sys.getsizeof() | gc.get_referrers() |
|---|---|---|---|
| The question | where was it allocated, and what grew? | how big is this one object? | who still refers to this object? |
| What it sees | every block Python allocates, with its line | the object alone, not what it refers to | the objects that hold a reference to it |
| Use it when | memory grows and you need the line | you want a quick size of one object | an object should be gone but is not |
Checkpoint
Check: A tool for each question
Check yourself
Nine short questions test snapshots, references, shallow sizes and the choice of a tool, and each new question depends on how you answered the last ones.
Practice that adapts to you
Practise this lesson
9 questions about 11 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Tracing growing memory to its line and its holder Pro: opens with a Pro, Premium or Ultimate pass
A snapshot difference names the line that allocated the memory; to fix it you need the call path that led there and the reference that keeps the memory alive. This part measures growth over time, follows an allocation through its callers, finds the object that holds it and bounds a cache.
In this part:
- Growth that never stops, measured batch by batch
- Which callers allocated it: tracebacks with several frames
- What tracing costs, measured
- A cycle that waits for the collector
- A checklist for memory that keeps growing
1 diagram · 4 runnable examples (Python) · about 11 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Memory budgets in numbers: records, peaks and bounded caches Pro: opens with a Pro, Premium or Ultimate pass
Once the leak is gone, the next question is how much memory the work needs at all. This part measures the cost of one record in seven representations, compares the peak of a list with a generator pipeline, and sizes a cache from a budget.
In this part:
- Bytes per record in seven representations, measured
- The peak of a list against a generator pipeline
- Sizing a bounded cache from a memory budget
- Exercise: a session store that stops growing
- Stop a session store from growing without limit
2 runnable examples (Python) · 1 exercise with sample tests · about 11 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
-
Fresher to mid · Warm-up
Why does sys.getsizeof() report much less memory for a list of objects than the list really uses?
Because sys.getsizeof() is shallow: it returns the memory of the object itself, not of the objects it refers to. For a list, that is its header and its array of references, 8 bytes per item on a 64-bit build, plus some spare room. The items are separate objects with memory of their own: in this lesson, a list of a million integers measured 8 MB with getsizeof() and about 40 MB with tracemalloc, because each integer is an object of about 32 bytes. Strings, dicts and instances in a list add their sizes in the same way, and an object shared by several containers exists only once. To see what a structure really costs, trace its construction with tracemalloc. For plain numbers, the array module stores the values themselves, so its getsizeof() is close to the truth.
-
Mid to senior
A Python web service's memory grows by about 200 MB a day until it is restarted. How do you find the cause, and how do you prove the fix?
-
Mid to senior
A function decorated with functools.lru_cache(maxsize=None) caches product prices by product id and date, and memory grows every day. How do you choose a bound, and what do you measure to show that the bound is right?
-
Senior to staff
A nightly job that processes a 4 GB CSV file is killed for running out of memory on an 8 GB machine. How do you find what takes the memory, and how do you make the job's memory independent of the file size?
Key takeaways
tracemalloc.start(), thentake_snapshot()andstatistics("lineno"), shows the lines that hold the most memory;compare_to()between two snapshots shows what grew.- Memory that grows is memory that something still refers to: a cache, a module-level list or registry, or a reference cycle waiting for the garbage collector.
sys.getsizeof()is shallow: a list of a million integers and the integers in it take about five times what it reports for the list.- Bounded caches, generators,
__slots__and arrays keep memory proportional to what a program needs at once.
References
- tracemalloc: trace memory allocations (Python Software Foundation)
- tracemalloc: compute differences between snapshots (Python Software Foundation)
- tracemalloc.reset_peak() (Python Software Foundation)
- gc: garbage collector interface (Python Software Foundation)
- gc.get_referrers() (Python Software Foundation)
- sys.getsizeof() (Python Software Foundation)
- functools.lru_cache() (Python Software Foundation)
- array: efficient arrays of numeric values (Python Software Foundation)
- collections.OrderedDict (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress