Python manages memory automatically, freeing developers from manual allocation and deallocation.
It does this through two complementary mechanisms: reference counting and a generational garbage collector for cyclic references.
This article covers how garbage collection works in CPython.
In other implementations such as PyPy, it works under a different mechanism Reference Counting: The Primary Mechanism Every object in Python carries a reference count, a tally of how many references point to it.
This count increments when: A new reference is assigned () It's stored in a container (list, dict, etc.) The object is passed into a function It decrements when: A reference goes out of scope A reference is reassigned is manually called on a reference When the count hits zero, CPython deallocates the object immediately.
This is a key difference from garbage-collected languages like Java, JS or the PyPy implementation, where collection timing is unpredictable.
The Problem: Reference Cycles Reference counting alone cannot handle cyclic references, where objects reference each other and keep their counts above zero even when unreachable from the program: This causes a memory leak.
The Solution: Generational Garbage Collector To catch scenarios like the above, Python includes a separate cyclic garbage collector, implemented in the module.
It's based on the generational hypothesis: most objects die young, so recently created objects are checked more frequently than long-lived ones.
Objects are organized into three generations: Generation Description Collection Frequency 0 Newly created objects Most frequent 1 Survived one collection Less frequent 2 Survived multiple collections Least frequent An object starts in generation
0.
If it survives a collection pass, it's promoted to generation 1, then eventually generation
2.
Each generation has a threshold count of allocations that triggers a collection pass: This means generation 0 collects after 700 net allocations; generations 1 and 2 collect after 10 collections of the preceding generation.
How Cycle Detection Works The cyclic collector only tracks container objects: lists, dicts, tuples, class instances, closures - anything capable of holding a reference to another object.
Each tracked object carries a struct linking it into a per-generation list.
When a generation's allocation-minus-deallocation count crosses its threshold, a collection pass runs against that generation: Copy each tracked object's refcount into a scratch field.
Subtract references that come from other tracked objects, this strips out the "internal" cycle references.
Anything left with a positive count must be referenced from outside the tracked set, so it's kept.
Trace outward from those objects to rescue anything reachable from them, even if it's inside a cycle.
Whatever's still at zero is unreachable and gets swept away.
Objects that survive a pass move to the next generation and get checked less often.
The time complexity of this whole operation is O(n).
Should we mess with the cyclic collector?
Usually not. however...
Sometimes disabling the cyclic collector can be done in performance-sensitive code that avoids creating reference cycles.
Practical Implications Watch allocation-heavy loops Generation-0 collection triggers after a net allocation threshold (default 700), so code that repeatedly creates and discards containers can trigger frequent passes: The second version creates one dict instead of a million, so it crosses the gen-0 threshold far less often.
Break likely cycles with Parent/child structures are a classic example: a Node with a .parent and .children list creates a cycle every time a child is added: doesn't contribute to root's refcount at all.
So when runs, root's refcount actually hits zero, same for child on .
No cycle ever exists, so there's nothing for the cyclic collector to have to catch later.