Python gc Module Internals — Deep Dive

The collection algorithm

CPython’s cyclic garbage collector does not use the classical mark-and-sweep algorithm. Instead, it uses a variant based on reference count subtraction (sometimes called trial deletion):

  1. Build candidate set — all container objects in the target generation(s) are gathered.
  2. Subtract internal references — for each object in the set, decrement a temporary copy of the reference count for every reference it holds to another object in the set. This removes “internal” references within the candidate set.
  3. Find roots — objects whose adjusted reference count is still greater than zero are reachable from outside the set. They are “roots.”
  4. Mark reachable — starting from roots, traverse all references and mark objects as reachable.
  5. Collect unreachable — everything not marked as reachable is garbage. Free it.
import gc
import sys

class Node:
    def __init__(self, name):
        self.name = name
        self.ref = None

# Create a cycle
a = Node("A")
b = Node("B")
a.ref = b
b.ref = a

# Reference counts include the local variables
print(f"refcount(a): {sys.getrefcount(a)}")  # 2 (a + getrefcount arg)
print(f"refcount(b): {sys.getrefcount(b)}")  # 2

# Remove local references — objects only referenced by each other
del a, b

# Force collection and see what was collected
gc.set_debug(gc.DEBUG_COLLECTABLE | gc.DEBUG_STATS)
collected = gc.collect()
print(f"Collected: {collected} objects")
gc.set_debug(0)

Generational promotion mechanics

When a generation-0 collection runs:

  1. Objects in generation 0 are scanned for cycles.
  2. Unreachable objects are freed.
  3. Surviving objects are promoted to generation 1.
  4. The generation-0 collection counter resets, and the generation-1 counter increments.
  5. When the generation-1 counter exceeds its threshold, generations 0 and 1 are collected together, and survivors are promoted to generation 2.

Generation 2 is special: it is the oldest generation and objects stay there permanently. To avoid scanning the entire heap too often, generation-2 collections use a ratio-based heuristic. The collector only runs a full (gen-2) collection when the ratio of long-lived objects to total objects justifies the work.

import gc

# Monitor collection counts
print(f"Collection counts: {gc.get_count()}")
# Returns (gen0_count, gen1_count, gen2_count)
# gen0_count = allocations - deallocations since last gen-0 collection

Finalization and del

Before Python 3.4, objects with __del__ methods involved in reference cycles were considered “uncollectable” — the collector could not determine a safe order to call their finalizers. These objects were placed in gc.garbage for manual inspection.

Python 3.4 (PEP 442) introduced safe finalization ordering:

import gc

class Resource:
    def __init__(self, name):
        self.name = name
        self.partner = None

    def __del__(self):
        print(f"Finalizing {self.name}")

# Even with __del__ and a cycle, Python 3.4+ can collect these
r1 = Resource("R1")
r2 = Resource("R2")
r1.partner = r2
r2.partner = r1
del r1, r2

gc.collect()  # Prints finalization messages, objects are freed

The modern collector uses a topological sort on the reference graph to determine finalization order. If no safe order exists, it still collects the objects but may call __del__ in an arbitrary order.

Weakref integration

Weak references (via the weakref module) interact with the gc in important ways. A weak reference does not prevent garbage collection, and the gc clears weak references before calling __del__:

import weakref
import gc

class Tracked:
    pass

obj = Tracked()
ref = weakref.ref(obj, lambda r: print("Weak reference cleared"))

del obj
gc.collect()
# "Weak reference cleared" is printed
# The weakref callback fires BEFORE __del__ (if any) would be called

This ordering guarantees that __del__ methods never see dangling weak references.

Production tuning strategies

Strategy 1: Tune thresholds for your workload

import gc

# Default: (700, 10, 10)
# For a web server handling many short-lived requests:
gc.set_threshold(1000, 15, 15)
# Higher gen-0 threshold = fewer collections
# Higher gen-1/2 multipliers = less frequent full collections

Strategy 2: Disable gc during latency-sensitive operations

import gc

gc.disable()
try:
    process_critical_request()
finally:
    gc.enable()
    # Optionally run collection at a convenient time
    gc.collect()

Strategy 3: The Instagram approach

Instagram’s engineering team (2017) found that gc pauses caused p99 latency spikes. Their solution:

  1. Disable gc entirely with gc.disable()
  2. Avoid reference cycles in application code
  3. Use weakref for parent-child relationships
  4. Fork worker processes after gc is stable (copy-on-write optimization)

The copy-on-write aspect is crucial: when a process forks, the OS shares memory pages until one process modifies them. CPython’s gc modifies reference counts and object headers during collection, causing massive copy-on-write amplification. Disabling gc keeps shared pages intact.

Strategy 4: Manual collection timing

import gc
import time

class GCScheduler:
    def __init__(self, interval_seconds=30):
        self.interval = interval_seconds
        self.last_collect = time.monotonic()
        gc.disable()

    def maybe_collect(self):
        now = time.monotonic()
        if now - self.last_collect >= self.interval:
            gc.collect()
            self.last_collect = now

    def collect_between_requests(self):
        """Call between request handling for web servers."""
        gen0, gen1, gen2 = gc.get_count()
        if gen0 > 2000:  # Only if significant allocation pressure
            gc.collect(0)  # Quick gen-0 only

Debugging memory leaks with gc

A systematic approach to finding reference cycles:

import gc
import sys
from collections import Counter

def find_leaking_types():
    """Identify types that are accumulating in the gc."""
    gc.collect()
    type_counts = Counter(type(obj).__name__ for obj in gc.get_objects())
    return type_counts.most_common(20)

def find_cycle_for_object(target):
    """Find reference cycle involving a specific object."""
    gc.collect()
    referrers = gc.get_referrers(target)
    for ref in referrers:
        if ref is target:
            continue
        # Check if this referrer is also referred to by target
        target_referents = gc.get_referents(target)
        if ref in target_referents:
            print(f"Cycle: {type(target).__name__} <-> {type(ref).__name__}")
            return ref
    return None

def snapshot_diff():
    """Compare object counts between two points in time."""
    gc.collect()
    before = Counter(type(obj).__name__ for obj in gc.get_objects())

    yield  # Caller does work here

    gc.collect()
    after = Counter(type(obj).__name__ for obj in gc.get_objects())

    diff = after - before
    print("New objects since snapshot:")
    for type_name, count in diff.most_common(10):
        print(f"  {type_name}: +{count}")

GC callbacks (Python 3.3+)

Register functions to be called before and after collection:

import gc
import time

def gc_callback(phase, info):
    if phase == "start":
        gc_callback._start = time.perf_counter()
    elif phase == "stop":
        elapsed = time.perf_counter() - gc_callback._start
        gen = info["generation"]
        collected = info["collected"]
        print(f"GC gen-{gen}: collected {collected} objects "
              f"in {elapsed*1000:.2f}ms")

gc.callbacks.append(gc_callback)

This is the production-grade way to monitor gc impact on your application’s latency.

The freeze() function (Python 3.7+)

gc.freeze() moves all tracked objects into the permanent generation, preventing the collector from ever scanning them. This is designed for the fork-based server pattern:

import gc

# During application startup (before forking workers)
gc.collect()   # Clean up any startup garbage
gc.freeze()    # Move everything to permanent generation

# Now fork workers — the frozen objects won't cause
# copy-on-write when gc runs in child processes

gc.unfreeze() reverses this, and gc.get_freeze_count() tells you how many objects are frozen.

The one thing to remember: CPython’s cyclic gc uses reference-count subtraction (not mark-and-sweep) across three generations, and production tuning means controlling when collections happen relative to your application’s latency-sensitive paths — especially in forked process architectures where gc-triggered copy-on-write amplification is the real enemy.

pythonmemory-managementinternals

See Also

  • Python Ast Module Code Analysis How Python's ast module reads your code like a grammar teacher diagrams sentences — turning source text into a tree you can inspect and change.
  • Python Dis Module Bytecode How Python's dis module lets you peek at the secret instructions your computer actually runs when it executes your Python code.
  • Python Importlib Custom Loaders How Python's importlib lets you teach Python to load code from anywhere — databases, zip files, the internet, or even generated on the fly.
  • Python Site Customization How Python's site module sets up your environment before your code even starts running — the invisible first step of every Python program.
  • Python Startup Optimization Why Python takes a moment to start and what you can do to make your scripts and tools launch faster.