Memory Allocator, PyObject, and Garbage Collection

Updated

September 7, 2026

Memory Allocator, PyObject, and Garbage Collection

After reading this chapter, you will master CPython’s memory architecture: inspect PyObject headers (ob_refcnt, ob_type), understand pymalloc arenas and pools, analyze reference counting deallocation, diagnose circular reference leaks with the gc module, and prevent caching memory leaks using weakref.

Mental model

In CPython, every object on the heap begins with a common C struct header (PyObject):

CPython PyObject Memory Header:
  ┌────────────────────────────────────────────────────────┐
  │ ob_refcnt (64-bit integer tracking active references) │
  │ ob_type   (Pointer to type object: e.g. &PyLong_Type) │
  ├────────────────────────────────────────────────────────┤
  │ Object-specific payload (e.g. integer digits, chars)  │
  └────────────────────────────────────────────────────────┘

CPython uses two complementary memory reclamation systems: 1. Reference Counting: Primary deallocation mechanism. The instant ob_refcnt drops to zero, the object’s destructor is called and memory is reclaimed immediately with zero latency. 2. Generational Cyclic Garbage Collector (gc): Runs periodically in the background to detect and break isolated reference cycles (A -> B -> A) that reference counting cannot reclaim.

Pymalloc Allocator Hierarchy:
  System Virtual Memory (malloc)
            │
            ▼ 256 KB Arenas
  [ Arena 1 ]  [ Arena 2 ]  [ Arena 3 ]
        │
        ▼ 4 KB Pools (Dedicated to specific size classes: 8, 16, 24, ... 512 bytes)
    [ Pool 0 ]   [ Pool 1 ]   [ Pool 2 ]
          │
          ▼ Blocks (Individual object memory slots)
      [ Blk ] [ Blk ] [ Blk ]

Minimal example

Save as memory_gc_showcase.py:

# memory_gc_showcase.py
import gc
import sys

class ClusterNode:
    def __init__(self, name: str) -> None:
        self.name = name
        self.peer: "ClusterNode | None" = None

def test_circular_reference() -> None:
    # Temporarily disable automatic GC to observe cyclic leak
    gc.disable()

    # Create two nodes that reference each other (Circular Reference)
    node1 = ClusterNode("alpha")
    node2 = ClusterNode("beta")
    node1.peer = node2
    node2.peer = node1

    # Remove stack references; reference count stays at 1 due to the cycle!
    del node1
    del node2

    print("Stack references deleted, but cyclic objects remain in heap memory.")
    uncollected_before = gc.collect()
    print(f"Manual gc.collect() identified and reclaimed: {uncollected_before} unreachable cyclic objects.")

    gc.enable()

def main() -> None:
    test_circular_reference()

if __name__ == "__main__":
    main()

Run via uv run python memory_gc_showcase.py:

Stack references deleted, but cyclic objects remain in heap memory.
Manual gc.collect() identified and reclaimed: 4 unreachable cyclic objects.

Worked examples

Case 1: Reference Counting Mechanics with sys.getrefcount()

Every time an object is passed into a function, stored in a list, or assigned to a name, ob_refcnt increments. When a reference leaves scope, it decrements:

# refcount_tracer.py
import sys

def trace_reference_counts() -> None:
    # Note: sys.getrefcount() temporarily increments refcount by 1 because it receives the object as an argument!
    target = ["production_cluster_token"]
    print(f"Initial reference count      : {sys.getrefcount(target) - 1}")

    alias1 = target
    print(f"After alias1 = target        : {sys.getrefcount(target) - 1}")

    registry = [target]
    print(f"After adding to list         : {sys.getrefcount(target) - 1}")

    del alias1
    print(f"After del alias1             : {sys.getrefcount(target) - 1}")

    registry.clear()
    print(f"After clearing list registry : {sys.getrefcount(target) - 1}")

if __name__ == "__main__":
    trace_reference_counts()

Run:

uv run python refcount_tracer.py

Output:

Initial reference count      : 1
After alias1 = target        : 2
After adding to list         : 3
After del alias1             : 2
After clearing list registry : 1

Case 2: Leak-Free Caching with weakref.WeakValueDictionary

Standard dictionaries keep strong references to cached objects, preventing the garbage collector from ever freeing them. A WeakValueDictionary discards cache entries automatically the moment no other strong references exist:

# weakref_cache.py
import weakref

class ExpensiveSession:
    def __init__(self, session_id: str) -> None:
        self.session_id = session_id

def main() -> None:
    cache = weakref.WeakValueDictionary()

    # Create active session
    sess = ExpensiveSession("sess_9901")
    cache["sess_9901"] = sess

    print("Session cached. Is in cache?", "sess_9901" in cache)
    print("Cached value:", cache.get("sess_9901"))

    # When the strong reference leaves scope or is deleted:
    del sess

    print("\nStrong reference deleted.")
    print("Is session still in WeakValueDictionary?", "sess_9901" in cache)

if __name__ == "__main__":
    main()

Run:

uv run python weakref_cache.py

Output:

Session cached. Is in cache? True
Cached value: <__main__.ExpensiveSession object at ...>

Strong reference deleted.
Is session still in WeakValueDictionary? False

Pitfalls

Pitfall 1: Believing del Immediately Frees OS RAM

The del statement does not free memory directly; it only removes a name binding and decrements the object’s reference count. Furthermore, even when an object is destroyed, CPython’s pymalloc allocator typically keeps the 4KB pool allocated to reuse for future Python objects rather than releasing it back to the kernel.

Pitfall 2: Disabling the Garbage Collector in Long-Running Daemons

While disabling gc (gc.disable()) can improve performance in short-lived CLI batch scripts by skipping cyclic scans, doing so in long-running services (FastAPI, Celery workers) causes gradual memory leaks whenever circular references are formed.


Exercises

  1. Create a doubly linked list node class with prev and next pointers, form a cycle, and verify with gc.garbage that Python’s cyclic collector identifies it.
  2. Use gc.get_threshold() to inspect the collection thresholds for generation 0, 1, and 2.
  3. Implement an object pool cache using weakref.ref and demonstrate registering a finalizer callback via weakref.finalize.
  4. Measure the time required to allocate and deallocate 1,000,000 small integer objects versus 1,000,000 large dictionary objects.

Further reading

  • Python Standard Library: gc and weakref modules.
  • CPython Internal Documentation: Objects/obmalloc.c (pymalloc design).
  • PEP 442: Safe object finalization.