Two explanations of Python object lifetime circulate because both are partly true:
- "Python uses garbage collection."
- "CPython destroys an object when its reference count reaches zero."
CPython uses both mechanisms. Reference counting handles the common case promptly. A cyclic garbage collector handles unreachable groups whose members keep one another's counts above zero. Calling only the second mechanism "the GC" is common shorthand, but it can hide the more useful model.
This distinction explains why a local object often disappears immediately, why a self-referencing object does not, why gc.disable() does not disable reference counting, and why holding a traceback can retain a surprising amount of application state.
Python guarantee. Objects may be collected after they become unreachable, but the language allows an implementation to postpone collection or omit it. Code must not depend on prompt destruction. CPython's reference counts, cyclic collector, generation policy, and object layout are implementation details.
Version note. Experiments were run on the standard GIL-enabled CPython 3.14.7 build. Python 3.14.0 removed generation 1, but 3.14.5 restored it to preserve 3.13 behavior. The free-threaded CPython build uses a different, non-generational cyclic collector and different reference-count structures.
References are edges, not ownership labels
Assignment does not copy an arbitrary object. It binds another name to the same object:
records = []
pending = records
The names are two incoming edges to one list. A container adds edges to its contents; an instance usually adds edges through attributes; a function adds edges to globals, defaults, and closure cells. "Who owns this object?" is often less precise than "what paths still reach this object?"
At the language level, an object has identity, type, and value. id() is unique and constant only during that object's lifetime; a later object may reuse the same value. CPython currently uses the memory address as id(), but address reuse is another reason never to use bare IDs as durable identities.
Experiment 1: observe aliases changing a reference count
sys.getrefcount() exposes CPython's count for diagnostics.
import sys
item = object()
print(sys.getrefcount(item))
alias = item
print(sys.getrefcount(item))
del alias
print(sys.getrefcount(item))
Our CPython 3.14 run prints 2, 3, then 2. Each call temporarily receives its own reference to item, so the displayed count is one higher than the simple named-reference count.
Do not turn these numbers into application logic. Temporary references from function calls, containers, debuggers, interactive history, and implementation optimizations affect them. Since Python 3.12, immortal objects add an even sharper exception:
import sys
print(sys.getrefcount(None))
print(sys._is_immortal(None))
On this build, None reports 3221225472 and True. That huge value does not mean billions of useful references exist. sys._is_immortal() is private and CPython-specific. The practical rule is simple: use getrefcount() to compare carefully controlled states of ordinary objects, not to ask whether production code may mutate, free, or uniquely own an object.
At the C API, Py_INCREF() and Py_DECREF() are described as taking and releasing strong references. Once the last strong reference is released, deallocation can invoke arbitrary Python through finalizers. Correct extension code must finish restoring its own invariants before releasing a reference.
Experiment 2: prompt disposal is a CPython behavior
A weak reference lets us observe lifetime without keeping the target alive.
import weakref
class Marker:
pass
obj = Marker()
observer = weakref.ref(obj)
print(observer() is obj)
del obj
print(observer())
On ordinary CPython, the second print is None immediately: deleting the only strong reference drops the count to zero. No cyclic collection was required.
This is useful behavior, not portable lifecycle control. PyPy and other implementations may collect later, and CPython itself does not promise prompt finalization as a language rule. Use with for files, locks, transactions, and temporary resources:
with open("report.txt", "w", encoding="utf-8") as report:
report.write("complete\n")
The context manager expresses the release point even if the object remains reachable or collection is delayed.
Why a cycle defeats counting
Suppose objects A and B refer to each other. After all external references disappear, A still has an incoming reference from B and B has one from A. Neither count reaches zero. Yet no running program can reach either object from roots such as active frames, module namespaces, or builtins.
Reference counting asks a local question: "How many strong references point here?" Cycle collection asks a graph question: "Can anything outside this candidate group reach here?"
Experiment 3: make the distinction visible
import gc
import weakref
class Node:
pass
left = Node()
right = Node()
left.other = right
right.other = left
left_ref = weakref.ref(left)
right_ref = weakref.ref(right)
del left, right
print(left_ref() is not None, right_ref() is not None)
collected = gc.collect()
print(collected >= 2, left_ref(), right_ref())
Before explicit collection, both nodes still exist on our CPython run. Afterwards, the weak references return None. The exact collected count is not fixed: instance dictionaries and unrelated pending garbage may also be counted, so the experiment checks only a lower bound.
Try wrapping gc.disable() around the construction. Acyclic objects still disappear when their counts hit zero; the cycle remains until explicit gc.collect() or until automatic cyclic collection is re-enabled and later runs. Disabling the collector is therefore safe only when you know the workload creates no cycles or when you deliberately manage collection around a measured critical section.
How CPython identifies cyclic garbage
The standard GIL-enabled collector tracks container-like objects that can participate in cycles. At collection time, it takes a candidate generation and conceptually copies each candidate's real reference count into temporary GC state. It traverses references between candidates and subtracts those internal edges. A positive remainder means an outside reference reaches that object. Reachability is then propagated through the group. Candidates not reached from outside are cyclic isolates.
This is not a recursive walk consuming one C stack frame per Python edge. CPython reuses bookkeeping fields attached to tracked objects and partitions linked lists of candidates. Types implemented in C cooperate through tp_traverse, which reports relevant outgoing references, and usually tp_clear, which breaks them during disposal.
Once an isolate is found, disposal is delicate: weak references are cleared, eligible callbacks run, finalizers run, resurrection is checked, and then internal links are cleared so normal decrements can finish deallocation.
CPython detail. In a normal build, GC-tracked objects have a
PyGC_Headbefore the ordinary object header. The free-threaded build instead stores GC bits in its larger object header and scans objects through its allocator. Application code should depend on neither layout.
Experiment 4: tracked does not mean leaked
gc.is_tracked() reports whether the cyclic collector currently tracks an object.
import gc
values = [
0,
[],
{},
{"answer": 42},
(1, 2),
([1], 2),
]
for value in values:
print(type(value).__name__, repr(value), gc.is_tracked(value))
On CPython 3.14.7, integers are not tracked, lists are tracked, dictionaries are tracked, an all-atomic tuple is untracked, and a tuple containing a list is tracked. A full collection may untrack eligible tuples.
This changed recently. CPython through 3.13 could leave empty or atomic-only dictionaries untracked; 3.14 removed that optimization because checking on every insertion cost more than the full-collection savings. Treat is_tracked() as a diagnostic observation for the current runtime, never a semantic property of a container type.
Tracking does not mean an object is garbage, has a cycle, or consumes unusual memory. It means the collector may need to consider it. Likewise, an untracked atomic object is still reclaimed by reference counting.
Generations optimize a hypothesis
Most objects die young. The default CPython collector exploits that weak generational hypothesis by putting new tracked objects in generation 0. Survivors move to older generations, which are scanned less often. Python 3.14.5 again exposes three generations, numbered 0 through 2.
import gc
print(gc.get_threshold())
print(gc.get_count())
print(gc.get_stats())
On this build the default thresholds are (2000, 10, 10), but they are tuning inputs, not constants to bake into monitoring alerts. get_count() reports current collection counters. get_stats() returns cumulative collections, collected, and uncollectable values for each generation.
Automatic collection begins based primarily on allocations minus deallocations crossing thresholds. In the free-threaded build, process-memory growth also influences whether a scheduled collection runs, and every collection scans the whole heap rather than using generations. This is a concrete reason to qualify GC tuning advice by build, not just by sys.version_info.
Experiment 5: measure collection with callbacks
gc.callbacks can observe collections without scraping debug output.
import gc
events = []
def record(phase, info):
if phase == "stop":
events.append((info["generation"], info["collected"]))
gc.callbacks.append(record)
try:
cycle = []
cycle.append(cycle)
del cycle
gc.collect(0)
finally:
gc.callbacks.remove(record)
print(events[-1][0], events[-1][1] >= 1)
The final line is 0 True in the isolated experiment. In a busy process, callbacks run synchronously during GC and can observe other garbage too. Keep them fast, avoid allocating heavily inside them, and aggregate timings rather than logging every object.
Tune only from evidence. Raising thresholds may reduce pause frequency while increasing retained cyclic garbage. Calling full gc.collect() on every request can turn a throughput concern into repeated scans of long-lived state. First record collection duration, frequency, generation, and allocation behavior under representative load.
Experiment 6: inspect edges carefully
gc.get_referents() follows outgoing GC-visible edges. gc.get_referrers() finds GC-aware containers with incoming edges.
import gc
payload = []
holder = {"payload": payload}
print(any(value is payload for value in gc.get_referents(holder)))
print(any(value is holder for value in gc.get_referrers(payload)))
Both checks print True. This looks like a heap-query API, but it is intentionally a debugging tool. get_referrers() does not find every possible C-level reference, may return partially constructed objects, and creates a result list containing strong references. Your local variables, debugger, REPL result cache, traceback, and inspection result can all become new reasons the target stays alive.
A disciplined leak investigation uses a short-lived diagnostic process or snapshot, calls gc.collect() first to clear already unreachable cycles, records types and paths rather than retaining whole objects, and deletes inspection results promptly. tracemalloc complements graph inspection by attributing Python allocations to source locations; it does not identify every native allocation or prove reachability.
Finalizers, resurrection, and gc.garbage
Since Python 3.4 and PEP 442, ordinary objects with __del__() can be collected even inside cycles. CPython finalizes objects while the cyclic isolate is still intact, checks whether a finalizer resurrected anything, then clears references. Finalizer order within an isolate is undefined, and exceptions from __del__() are reported but cannot propagate normally.
Resurrection makes lifetime reasoning difficult:
import gc
saved = None
class Lazarus:
def __del__(self):
global saved
saved = self
obj = Lazarus()
del obj
print(saved is not None, gc.is_finalized(saved))
saved = None
gc.collect()
On CPython this prints True True: the finalizer ran and restored reachability. It is not called a second time when the resurrected object later dies. Avoid resurrection in application design; it creates global, order-sensitive state precisely when the object graph is being dismantled.
gc.garbage should normally remain empty. Legacy C extension types using the old tp_del slot can still be uncollectable. More commonly, investigators fill gc.garbage themselves by enabling DEBUG_SAVEALL, which deliberately saves every unreachable object instead of freeing it. Remember to disable the flag and clear the list after diagnosis.
Experiment 7: a finalizer can retain its target
weakref.finalize() is often cleaner than __del__(), but its callback and arguments must not refer back to the target.
import gc
import weakref
class Service:
def close(self):
pass
service = Service()
finalizer = weakref.finalize(service, service.close) # bound method retains service
observer = weakref.ref(service)
del service
gc.collect()
print(observer() is not None, finalizer.alive)
finalizer.detach()
gc.collect()
print(observer())
The first line is True True; after detaching, the observer returns None. The finalizer registry keeps the bound method alive, the bound method keeps service alive, and collection never gets the trigger it needs.
Prefer a callback that receives only independent cleanup state:
self._finalizer = weakref.finalize(self, close_handle, self.handle_id)
Even then, explicit close() plus context-manager support should be the primary API for important resources. The finalizer is a safety net.
Experiment 8: weak containers encode lifetime policy
A normal cache owns its values strongly. A WeakValueDictionary allows entries to disappear when no strong owner remains.
import weakref
class Image:
pass
cache = weakref.WeakValueDictionary()
image = Image()
cache["hero"] = image
print("hero" in cache)
del image
print("hero" in cache)
On CPython the second check is immediately false; elsewhere it may remain true until collection. Code using weak containers must already tolerate disappearance between operations. Retrieve and hold one strong local reference while using an entry.
Weak references are a semantic choice, not a generic leak cure. They work well for caches, metadata keyed by externally owned objects, and observer registries. They are wrong when the collection is supposed to own its elements. Also note that not every type supports weak references; slotted classes need a __weakref__ slot unless generated with appropriate weak-reference support.
Common false leaks
Growing resident memory does not prove unreachable Python objects are accumulating. Several layers intervene:
- live application caches may intentionally retain objects;
- tracebacks retain frames, whose locals retain object graphs;
- loggers, task registries, callbacks, and globals may provide forgotten roots;
- cyclic garbage may await collection;
- CPython free lists and its allocator may retain freed blocks for reuse;
- the platform allocator may not return arenas to the operating system;
- native extensions may allocate memory outside
tracemalloc's view.
Start by deciding what increased: reachable object count, traced Python bytes, allocator blocks, or process RSS. Each asks for a different tool. For example, sys.getsizeof() is shallow and cannot establish retained graph size; gc.get_objects() omits untracked objects and creates its own large list.
Practical decisions
- Use
with,try/finally, and idempotentclose()methods for deterministic cleanup. - Treat CPython's prompt acyclic disposal as an optimization, not an API contract.
- Break avoidable long-lived cycles, especially callbacks or parent/child links, when doing so simplifies ownership.
- Use weak containers only when another part of the system genuinely owns the objects.
- Keep tracebacks and frames out of long-lived error stores, or call
traceback.clear_frames()before retention. - Instrument GC before tuning thresholds or scheduling manual full collections.
- Never infer a leak from one refcount, one RSS sample, or
gc.is_tracked(). - Audit C extensions when Python-level reachability and native memory measurements disagree.
Exercises
- Create an acyclic object and a self-cycle while automatic GC is disabled. Observe both through weak references after deleting external names.
- Build a three-node cycle with one externally reachable node. Explain why none of it is garbage, then remove the root and collect.
- Capture an exception traceback whose frame contains a large object. Use weak references and
traceback.clear_frames()to study retention. - Compare
WeakValueDictionarywith a normal dictionary under delayed collection. List the race your cache consumer must tolerate. - Register a GC callback that records elapsed time per generation, then run a representative workload without printing from the callback.
Keep this model
CPython object lifetime is mostly reference-count driven. Strong references form a directed graph. Removing an edge decrements a count; reaching zero starts deallocation and can cascade through contained references. This gives prompt cleanup for most acyclic objects.
Cycles keep their own counts positive, so a second collector periodically examines tracked containers. It discounts candidate-to-candidate edges, finds groups with no outside path, handles weak references and finalizers, then breaks internal links so reference counting can finish the job.
Neither mechanism is a substitute for explicit resource management. Nor does either promise that freed Python objects immediately reduce process RSS. When memory surprises you, ask separately: is the object reachable, is it awaiting cyclic collection, has Python freed it for allocator reuse, and has the allocator returned memory to the OS? That sequence turns "the GC is leaking" into questions you can actually test.