How Tracemalloc Traces Memory Blocks in Python

Python’s built-in tracemalloc module provides deep visibility into memory usage by tracking individual memory blocks allocated by the interpreter. It works by hooking directly into CPython's internal memory management APIs to record allocation events alongside their corresponding Python tracebacks. This article explores how tracemalloc operates under the hood, detailing its integration with CPython memory allocators, its traceback capture mechanism, and how it manages tracking overhead.

Hooking CPython Allocators

CPython structures its memory management into three primary allocation domains defined by PEP 445:

When tracemalloc.start() is called, the module replaces the active memory hooks in these domains using PyMem_SetAllocator(). It installs custom wrapper functions around the standard allocation (malloc), reallocation (realloc), and deallocation (free) routines.

To avoid infinite recursion and skewed metrics, tracemalloc uses its own dedicated raw allocator that operates outside these traced domains when storing its internal tracking structures.

Capturing Tracebacks on Allocation

Whenever Python code allocates memory—such as instantiating an object or extending a list—the request passes through one of the hooked allocator functions. The hook executes the following steps:

  1. Underlying Allocation: It calls the original allocator function to retrieve the raw memory pointer.
  2. Stack Inspection: If the allocation succeeds, tracemalloc inspects the current execution state of the calling thread (PyThreadState).
  3. Frame Extraction: It walks the execution frames up to a user-defined limit (nframe, configured via tracemalloc.start(nframe)), collecting the filename and line number for each active Python frame.
  4. Trace Creation: It packages the pointer address, block size, and collected traceback into a compact internal structure called a trace.

Storage and Traceback Interning

Storing full stack traces for every memory allocation introduces significant memory overhead. To mitigate this, tracemalloc employs an internal hash table that interns tracebacks.

Each unique sequence of code locations (filename and line number pairs) is stored once in a global table and assigned a reference count. If multiple allocations occur from the exact same call stack, they store a pointer to the same interned traceback structure rather than duplicating the frame list.

The allocated memory addresses are stored in an internal hash table mapping the memory block's address to its size and interned traceback reference.

Intercepting Deallocations

When a block of memory is released via free(), the installed deallocation hook intercepts the call. It extracts the pointer address and looks it up in the internal tracking table:

  1. Lookup: The hook finds the recorded trace associated with the memory address.
  2. Counter Updates: It decrements the module's running totals for allocated memory and block counts.
  3. Cleanup: It releases the reference to the interned traceback (freeing the traceback if its reference count drops to zero) and deletes the address entry from the hash table.
  4. Original Free: Finally, it passes the pointer to the underlying CPython or system free() function.

Reallocations (realloc) follow a hybrid path: the old pointer's trace is updated or replaced with the new address and size, updating running totals accordingly.

Generating Snapshots

When a user calls tracemalloc.take_snapshot(), tracemalloc iterates over its internal hash table of active memory blocks. It converts the raw C tracking data into immutable Python objects (Snapshot, Statistic, and Trace instances).

Because the module continuously updates its tracking table during runtime, snapshots represent an exact, point-in-time state of all live memory blocks currently allocated by Python code.