Python Garbage Collection: How It Works
Python garbage collection in CPython combines reference counting with a cyclic collector. Learn how cycles are found, how the gc module's generations and thresholds can be tuned, and how to debug memory leaks.
In CPython, the reference implementation of Python, garbage collection is a two-part system: reference counting for immediate cleanup and a cyclic garbage collector for objects that reference each other in cycles. Understanding both mechanisms is essential for writing memory-efficient Python and for diagnosing memory leaks that reference counting alone cannot catch. Other Python implementations may use different memory-management strategies.
Reference Counting: The First Line of Defense
In CPython, every object keeps a count of how many references point to it. When you assign an object to a variable, pass it to a function, or store it in a container, the reference count increments. When a reference goes out of scope or is deleted, the count decrements. When it reaches zero, CPython deallocates the object immediately.
import sys obj = [] print(sys.getrefcount(obj)) # 2: one from obj, one from the getrefcount call
The sys.getrefcount() function returns the actual count plus one because the argument itself temporarily holds a reference. This immediate deallocation is deterministic in CPython: as soon as the last reference disappears, the memory is reclaimed. That is why short-lived objects are reclaimed promptly.
Reference counting alone works well for most objects, but it fails when objects reference each other in a cycle. Consider two objects that point to each other. Even if no external references remain, their reference counts never drop to zero because each holds a reference to the other. This is where the cyclic garbage collector becomes necessary.
The Problem of Cyclic References
A cyclic reference occurs when a group of objects references each other, forming a loop. A common example is a parent-child relationship where the parent holds a list of children and each child holds a reference back to its parent.
class Node: def __init__(self, name): self.name = name self.parent = None self.children = [] parent = Node('parent') child = Node('child') parent.children.append(child) child.parent = parent # Remove external references del parent del child
After the del statements, the two objects still reference each other. Their reference counts are both 1, so they are never freed by reference counting. Reference counting alone cannot reclaim them. The cyclic garbage collector exists to find and collect these unreachable cycles; without it, cycles would accumulate and memory usage would grow without bound.
How the Cyclic Garbage Collector Works
CPython's cyclic garbage collector is a generational collector that runs periodically. It tracks container objects—objects that can hold references to other objects, such as lists, dictionaries, tuples, and class instances. It does not track simple values such as integers and strings because they cannot participate in cycles.
The collector divides tracked objects into three generations. New objects go into generation 0. When an object survives a collection of its generation, it is promoted to the next generation. The collector runs more frequently on younger generations because most objects die young.
The algorithm works by finding objects that are part of a cycle and have no external references. Conceptually, it subtracts references that are internal to a candidate group and then checks whether any references from outside the group remain. If none do, the whole cycle is unreachable and deallocated. This process is called cyclic garbage collection.
The gc Module: Inspecting and Controlling Collection
The gc module provides functions to interact with the cyclic garbage collector. You can enable or disable collection, trigger a collection manually, and inspect what objects are tracked.
import gc # Check if GC is enabled print(gc.isenabled()) # True # Force a full collection gc.collect() # See the current collection counters for each generation print(gc.get_count()) # generation 0, 1, and 2 counters
gc.get_count() returns the collection counters for the three generations, not the number of objects in each generation. After a full collection with no new allocations, these counters are 0. gc.collect() performs a full collection across all generations and returns the number of unreachable objects it found. This is useful when you know you have created many temporary cycles and want to reclaim memory immediately.
You can also disable the cyclic collector entirely with gc.disable(). This is rarely recommended because it can lead to unbounded memory growth if cycles are created. However, in some performance-critical applications, you might disable GC temporarily and run it manually at controlled points.
Tuning Garbage Collection Thresholds
The cyclic collector runs when the number of allocations minus deallocations exceeds a threshold. The thresholds are set per generation and can be adjusted with gc.set_threshold().
import gc # Set thresholds for generations 0, 1, and 2 gc.set_threshold(700, 10, 10)
The first threshold is for generation 0. When allocations minus deallocations since the last generation-0 collection exceed 700, a generation-0 collection is triggered. The second and third thresholds control how often generations 1 and 2 are collected relative to the previous generation. For example, with the defaults, generation 1 is collected roughly every 10 generation-0 collections, and generation 2 roughly every 10 generation-1 collections.
| Generation | Default Threshold | Meaning |
|---|---|---|
| 0 | 700 | Allocations minus deallocations since the last generation-0 collection |
| 1 | 10 | Generation-0 collections that trigger a generation-1 collection |
| 2 | 10 | Generation-1 collections that trigger a generation-2 collection |
Lowering the generation-0 threshold makes the collector run more often, which can reduce peak memory usage but adds overhead. Raising it reduces collection frequency, which may improve performance but can let memory grow between collections. The optimal setting depends on your workload. For long-running processes with many temporary objects, you might want a lower threshold. For short scripts, the default is usually fine.
Common Pitfalls and Performance Considerations
One common mistake is assuming that gc.collect() is needed after every large operation. In most cases, the automatic collector handles cycles well. Overusing gc.collect() can hurt performance because a full collection scans all tracked objects.
Another pitfall is creating cycles unintentionally with closures or callbacks. For example, a class instance that stores a lambda referencing itself can create a cycle. Using weak references (weakref) can break cycles when you do not need a strong reference.
import weakref class Node: def __init__(self): self._parent = None @property def parent(self): if self._parent is None: return None return self._parent() @parent.setter def parent(self, node): self._parent = weakref.ref(node) if node is not None else None
After child.parent = parent, the child holds only a weak reference to the parent. Calling child.parent returns the parent object if it is still alive, or None if it has been collected.
Performance-wise, the cyclic collector adds overhead to allocation and deallocation because it tracks container objects. For applications that allocate millions of short-lived objects, this overhead can become noticeable. In such cases, you can disable GC during the hot path and enable it later, but you must be careful to avoid memory leaks.
Debugging Memory Leaks with gc
The gc module can help identify objects that are not being collected. Use gc.get_objects() to list tracked objects, and gc.get_referrers() to find what references a specific object.
import gc # Collect first so unreachable cycles are removed from the tracked list. gc.collect() # Objects that remain tracked are still alive, although they may be uncollectable. leaked_nodes = [obj for obj in gc.get_objects() if isinstance(obj, Node)] print(len(leaked_nodes)) # To find why one of these objects is still alive, inspect its referrers. if leaked_nodes: node = leaked_nodes[0] for ref in gc.get_referrers(node): print(ref)
You can also use gc.DEBUG_SAVEALL to have the collector save unreachable objects in gc.garbage instead of freeing them. This is useful for inspecting what would have been collected.
import gc gc.set_debug(gc.DEBUG_SAVEALL) gc.collect() for obj in gc.garbage: print(type(obj), repr(obj))
Remember to clear gc.garbage after inspection to free the memory. This debugging approach is invaluable when you suspect a cycle or hidden reference is keeping objects alive. By combining gc.get_objects() with gc.get_referrers(), you can trace exactly why an object remains alive and then adjust your code to break the cycle or use weak references.