Reference Cycles and How They Form

A reference cycle is a set of objects that, following references from one to the next, eventually leads back to where you started — A points at B which points back at A, or a list that contains itself, or an exception whose traceback ultimately references the exception again. Cycles are the one structure that Reference Counting cannot reclaim: every object in a cycle is kept alive by another member of the same cycle, so each one’s reference count stays above zero even after the program has dropped every external handle to the whole group. The result is a memory leak that pure reference counting can never resolve — which is exactly why CPython runs the cyclic garbage collector as a backstop. This note explains what cycles are, why they defeat reference counting, the everyday patterns that create them accidentally, how to observe them, and how weakref lets you sidestep the cycle entirely. It is the motivation companion to the mechanism note The Cyclic Garbage Collector; facts here are grounded in the CPython 3.14.5 garbage-collector design doc and the gc / weakref module docs.

How reference counting normally reclaims memory

To see why cycles are special, first recall the normal case. CPython’s Reference Counting keeps an ob_refcnt field in every object’s header, counting how many places hold a reference to it — other objects, C globals, local variables. The design doc puts it plainly: “When an object’s reference count becomes zero, the object is deallocated. If it contains references to other objects, their reference counts are decremented.” You can watch the count move with sys.getrefcount (which reports one more than the true count, because calling it borrows a temporary reference):

>>> x = object()
>>> sys.getrefcount(x)
2
>>> y = x          # second reference
>>> sys.getrefcount(x)
3
>>> del y          # drop it again
>>> sys.getrefcount(x)
2

For an acyclic object graph this is complete: drop the last external reference and the count reaches zero, the object dies, and any objects it referenced have their counts decremented in turn — a cascade that frees an entire unreachable subtree promptly, with no collection pause. The whole scheme rests on a single assumption: that when nothing outside an object points at it, its count is zero. A cycle violates exactly that assumption.

Why a cycle defeats reference counting

The canonical minimal cycle, straight from the design doc:

>>> container = []
>>> container.append(container)
>>> sys.getrefcount(container)
3
>>> del container

The list now contains itself. Two references point at it: the name container and the list’s own internal slot (the third count getrefcount shows is its own temporary borrow). When del container removes the external name, the count drops to 1, not 0 — the internal self-reference remains. The design doc states the consequence directly: “even when we remove our reference to it … the reference count never falls to 0 because it still has its own internal reference. Therefore it would never be cleaned just by simple reference counting.” The list is now unreachable from every live name in the program, yet immortal under refcounting, because the only thing keeping it alive is itself. That is a leak.

The two-object case generalizes the same trap:

>>> a = {}
>>> b = {}
>>> a["b"] = b      # a references b
>>> b["a"] = a      # b references a  -> cycle
>>> del a, b        # drop both external names

After del a, b, neither dict is reachable from any name. But a’s count is still 1 (held by b["a"]) and b’s count is still 1 (held by a["b"]). Neither can fall to zero, because each is propped up by the other. The defining property of any reference cycle is this mutual propping: for every object in the cycle there exists another cycle member that references it, so removing all external references still leaves every internal count nonzero. Reference counting is a purely local decision — “is my count zero?” — and no local decision can ever observe that a whole group has become collectively unreachable. Detecting that requires the global, graph-level reasoning of the cyclic collector.

flowchart LR
    subgraph before["Before: del a, b"]
        VA(["name a"]) --> A1["dict a<br/>refcnt 2"]
        VB(["name b"]) --> B1["dict b<br/>refcnt 2"]
        A1 -->|a['b']| B1
        B1 -->|b['a']| A1
    end
    subgraph after["After: del a, b — unreachable, but NOT freed"]
        A2["dict a<br/>refcnt 1"]
        B2["dict b<br/>refcnt 1"]
        A2 -->|a['b']| B2
        B2 -->|b['a']| A2
    end
    style A2 fill:#e88,stroke:#900
    style B2 fill:#e88,stroke:#900

Diagram: two dicts referencing each other. Before del, each has refcount 2 (a name + the other dict). After del a, b the external names vanish but each dict’s count only drops to 1, held by the other — so neither reaches zero and reference counting leaks both. The insight to take away: it is the mutual internal reference, not the deletion, that traps the memory; the cyclic GC exists solely to recognize this group-level unreachability.

Where cycles really come from

Cycles are not an exotic edge case you have to construct deliberately; the design doc stresses that “many internal references needed by the interpreter create cycles everywhere.” The notable everyday sources:

Self-referential data structures. Graphs, doubly linked lists, and trees with parent pointers all contain cycles by design. A node that links to its neighbors, which link back, is a cycle the moment you build it — the Link example in the design doc (link_1 → link_2 → link_3 → link_1) is exactly this.

Parent ↔ child back-references. A tree where each child holds a self.parent reference and each parent holds self.children is a cycle between every parent and child. This is the single most common accidental cycle in application code — GUI widget trees, DOM-like document models, ORM relationship graphs.

Closures capturing their enclosing scope. A function defined inside another can capture a variable that (directly or transitively) references the function itself. The frame, the cell, and the closure can form a ring (see Cell Variables and Closures).

Exceptions and tracebacks. This one bites in production. The ring has three links: an exception object references its traceback through exc.__traceback__; the traceback references the stack frames it unwound through (each tb.tb_frame); and a frame’s local variables (frame.f_locals) can include the exception itself. Inside an except E as e: block, e is a local of the frame the traceback points at — so the chain e.__traceback__ → tb → tb.tb_frame → frame.f_locals['e'] → e closes back on itself. The design doc lists this explicitly: “Exceptions contain traceback objects that contain a list of frames that contain the exception itself.” The concrete failure:

errors = {}
def handle(key):
    try:
        risky()
    except Exception as exc:
        errors[key] = exc      # stash the exception in a long-lived dict

Storing exc keeps it alive past the except block, and because exc.__traceback__ still references the frame whose locals (heavy buffers, file handles, large request objects) were live when the error occurred, that entire frame is pinned too. The cyclic GC will eventually reclaim it, but until then the exception silently retains a whole call stack’s worth of memory. Python implicitly dels the except name at block exit precisely to break this ring, so within the block the leak is invisible; copying exc out (as above) defeats the safeguard. The standard fixes are to store traceback.format_exception(exc) (a string, no frame references) instead of the live exception, or to call exc.__traceback__ = None before stashing it.

Modules, classes, and instances. The design doc again: “Module-level functions reference the module’s dict … which in turn contains entries for the module-level functions”; and “instances have references to their class which itself references its module.” These structural cycles are pervasive and are precisely why the GC must scan modules, classes, and functions — and why, as of CPython 3.14, dictionaries are always GC-tracked from creation, even empty ones. (Earlier versions up to 3.13 lazily untracked atomic-only dicts; GH-127010 removed that machinery in 3.14 because the per-insertion bookkeeping cost outweighed the savings. You can see the change directly: on 3.14.5, gc.is_tracked({}) returns True, whereas the older gc documentation shows False.)

Caches and registries. A cache that maps a key to an object, where the object also holds a reference back to the cache (or to a key that references the cache), forms a cycle — and worse, even without a cycle a global cache holding strong references prevents its entries from ever being collected. This is where weak references come in (below).

The __del__ + self-reference case

A self-referential object that also defines a __del__ finalizer is the example that most sharply illustrates how the rules changed. Consider:

class Resource:
    def __init__(self):
        self.self_ref = self     # self-cycle: refcount can never reach 0
    def __del__(self):
        print("closing", self)
 
r = Resource()
del r                            # external name gone, but self_ref keeps it alive

After del r, the object is unreachable from any name but its ob_refcnt is still 1 (held by self.self_ref), so reference counting alone will never run __del__ or free it — a textbook cyclic leak. Before Python 3.4 this was a permanent leak: the collector refused to break a cycle containing an object with a __del__, because it could not determine a safe order to run the finalizers, and dumped such objects into gc.garbage to sit there forever. PEP 442 (Python 3.4) fixed this by adding a tp_finalize slot that lets the collector run finalizers before breaking the cycle’s references, then collect the objects (PEP 442). On modern Python the Resource above is finalized and reclaimed by the cyclic GC normally, and gc.garbage stays empty in ordinary programs (gc docs); today only C extension types with the legacy tp_del slot can still produce uncollectable objects.

Two caveats survive the fix. First, finalization is no longer prompt__del__ runs at the next collection, not at del. Second, PEP 442 is explicit that within a cycle “the order in which finalizers are called … is undefined,” so you cannot rely on one cyclic object being torn down before another. See Finalizers and the del Method.

Observing cycles and leaks

The gc module is the primary instrument. Several useful observations:

gc.collect() and its return value. A manual gc.collect() runs a full collection and returns the number of unreachable objects it found and processed. If you suspect a cycle leak, drop your references, call gc.collect(), and a nonzero return tells you cyclic garbage existed. The following was run on a real CPython 3.14.5 interpreter (the leading gc.collect() “primes” the heap so the second call’s count is not polluted by ambient garbage):

>>> import gc
>>> gc.collect()                # prime: clear pre-existing garbage
0
>>> class Node: pass
...
>>> a, b = Node(), Node()
>>> a.peer = b; b.peer = a      # cycle: a <-> b
>>> del a, b                    # drop both external names
>>> gc.collect()                # reclaims the two-node cycle
2

The return value 2 counts the two Node instances. (Whether each instance’s __dict__ adds to the count depends on the build — with the per-object inline-values dict layout used since 3.11 the two instances report as 2 here; the figure is empirical, not a fixed formula.)

gc.garbage. This list holds objects the collector found unreachable but could not free (uncollectable). On Python 3.4+ it should be empty almost always; a non-empty gc.garbage signals a genuine uncollectable cycle (today, essentially only from C extensions with legacy finalizers). Setting gc.set_debug(gc.DEBUG_SAVEALL) redirects all unreachable objects into gc.garbage so you can inspect what a collection would have freed.

gc.get_referrers(obj) / gc.get_referents(obj). These walk the object graph: get_referents returns what obj points at (via tp_traverse), get_referrers returns what points at obj. Chaining them lets you trace a cycle by hand. The docs warn get_referrers is for debugging only and may surface objects in an inconsistent state.

gc.DEBUG_LEAK. A convenience flag (DEBUG_COLLECTABLE | DEBUG_UNCOLLECTABLE | DEBUG_SAVEALL) that makes the collector report what it finds — useful for hunting leaks in long-running processes.

objgraph. A widely used third-party package (not standard library) that builds on gc.get_referrers/get_referents to visualize reference graphs — objgraph.show_backrefs([obj]) renders a Graphviz picture of what keeps an object alive, which is the fastest way to see an accidental cycle or a cache that won’t let go.

Resolved (2026-06-01)

objgraph is confirmed a third-party package (PyPI, pip install objgraph), not part of CPython. Its show_backrefs([obj], filename=...) and show_refs([obj], filename=...) functions — the ones referenced here — are verified against the objgraph documentation, which renders Graphviz pictures of back-references and references respectively. The CPython-native tools it builds on (gc.get_referrers, gc.get_referents) are verified against the gc docs.

Breaking cycles with weak references

The structural fix for an intentional back-reference — parent↔child, object↔cache — is to make one direction of the link weak, using the weakref module. A weak reference points at an object without incrementing its reference count: it lets you reach the target while it is alive but does not keep it alive. Because the weak link contributes no count, it cannot complete a refcount cycle, so the structure is reclaimed by ordinary reference counting the instant the strong references go away — the cyclic GC never has to get involved.

import weakref
 
class Parent:
    def __init__(self):
        self.children = []          # strong: parent owns children
 
class Child:
    def __init__(self, parent):
        self._parent = weakref.ref(parent)   # weak: child does NOT own parent
 
    @property
    def parent(self):
        return self._parent()        # call the weakref to dereference; None if gone
 
p = Parent()
c = Child(p)
p.children.append(c)
# When the last strong reference to p is dropped, p's refcount hits zero
# immediately: the child's weakref does not count. No cycle, no GC needed.

Line by line: the parent holds its children strongly (it should keep them alive), but each child holds its parent through weakref.ref, which returns a callable weak-reference object. Dereferencing it (self._parent()) yields the parent if it is still alive, or None if it has been collected. Because the weak link adds nothing to the parent’s ob_refcnt, deleting the last strong reference to the parent drops its count straight to zero and refcounting frees it at once — there is no cycle for the collector to find. The companion weakref.WeakValueDictionary and WeakKeyDictionary apply the same idea to caches: entries vanish automatically when their value (or key) is no longer referenced elsewhere, so the cache never leaks. The deeper mechanics — weakref objects, callbacks, how the collector clears them — are covered in Weak References.

The trade-off is responsibility: a weak reference can become dangling (None) at any time, so code must handle the “target is gone” case. Use a weak reference for the non-owning direction of a relationship — the child’s view of its parent, the cache’s view of its entries — and keep the owning direction strong.

One sharp caveat: not every object can be weakly referenced. The weakref docs state that class instances, Python-level functions, methods, sets, generators, and type objects support weak references, but built-in list and dict do not (you can only weakly reference a subclass of them). So the Parent/Child pattern above works because they are user-defined classes; you cannot replace a parent↔child cycle between two plain dicts with a weakref without first wrapping them in a class. For the common cache case, weakref.WeakValueDictionary and WeakKeyDictionary handle the wrapping for you: entries are “discarded when no strong reference to the value [or key] exists any more,” so “a large object [is] not kept alive solely because it appears in a cache or mapping.”

When you don’t need to worry

Most code never has to think about cycles: the cyclic GC runs automatically and reclaims them. The cases where it does matter are: (1) very tight memory budgets where you want objects freed promptly rather than at the next collection — refcounting frees acyclic garbage immediately, but cyclic garbage waits for a GC pass; (2) objects with expensive finalizers where collection order is unpredictable; and (3) programs that disable the GC (gc.disable()) for latency reasons and so must avoid creating cycles entirely, since nothing will reclaim them. For these, the weakref pattern above turns a cycle into an acyclic graph that refcounting handles on its own.

See Also