Trace what keeps Python objects alive, observe cycle reclamation, and distinguish object cleanup from process memory and resource lifetimes.
Trace what keeps Python objects alive, observe cycle reclamation, and distinguish object cleanup from process memory and resource lifetimes.
A batch finishes, its local variables disappear, and the process still holds more memory than expected. Calling gc.collect() changes nothing. Before treating that as a collector failure, separate three questions: is the object still reachable, has its Python storage been reclaimed, and has the process returned memory to the operating system?
Those questions belong to different layers. A cache can keep an object alive legitimately. A reference cycle can need cyclic collection. An allocator can retain released storage for reuse. One memory graph or one process metric rarely explains all three.
This follows Variables and Memory in Python: References and Mutability in Python for Backend Developers. Start with Basic of Python if names and objects are unfamiliar. All executable observations here use the standard, non-free-threaded CPython 3.14.4 build with the GIL enabled. A later section distinguishes documented Python 3.14.5 collector changes from that tested runtime.
A strong reference is an ordinary reference that participates in keeping an object alive. Local names, dictionary values, list elements, instance attributes, and many other objects can hold these references. Removing one reference does not imply that the object has become unreachable from the rest of the program.
CPython primarily manages ordinary object lifetimes using reference counting, supplemented by a cyclic collector. This is an implementation choice. The language data model permits other implementations and warns against depending on immediate finalization. The useful language-level question is which live program state can still reach the object.
Imagine a payload referenced by a local name and by a cache entry. Deleting the local name leaves the cache’s reference intact. The payload remains available because the cache can return it. Reference counting is not supposed to reclaim an object that the program can still use.
import weakref
class Payload:
pass
payload = Payload()
observer = weakref.ref(payload)
cache = {"current": payload}
del payload
assert observer() is cache["current"]
cache.clear()
assert observer() is None # Observed on the selected CPython GIL build.The class instances in this example support weak references. observer lets the experiment observe lifetime without acting as another strong owner. After the cache is cleared, the weak reference reports None on the selected CPython build. That last timing observation is not a portable cleanup guarantee for arbitrary Python implementations.
In a backend process, a “temporary” result may also be stored in a retry queue, a module-level registry, an unfinished task, or an exception traceback. The lexical scope that created it can finish while another owner continues to retain it. The object’s lifetime follows references, not the developer’s description of its purpose.
del payload removes that name’s binding. It does not broadcast a destroy request to every alias. For a normal dictionary, del cache["current"] removes an entry; for a list, deleting a slice removes the corresponding elements. Those operations can release references, but other owners may remain. The del specification defines these target-dependent operations.
Rebinding can have the same ownership consequence. Assigning payload = None stops that name from holding the earlier object. Whether this is the final strong reference depends on the entire graph. There is usually no reason to scatter del across ordinary functions whose local names will leave scope anyway.
Explicitly releasing a large temporary before the end of a long-running function can be useful, but first verify that no container, closure, or debugger still retains it. A misleading comment such as “free memory now” gives a stronger promise than the operation provides. “Remove this batch from the cache” describes a concrete and reviewable ownership change.
On the selected GIL-enabled CPython build, acquiring another ordinary strong reference to a fresh list increases its reference count. Releasing that alias decreases it. When an ordinary object’s count reaches zero, CPython can deallocate it and release the references it owns, potentially triggering further deallocations.
sys.getrefcount() exposes a diagnostic count, not a stable application contract. Its result generally includes a temporary argument reference, so measuring can affect the count. Use the same measurement pattern before and after one controlled change rather than interpreting a raw number as the number of names in your source.
import sys
value = [] # Fresh object, not a cached integer or literal string.
baseline = sys.getrefcount(value)
alias = value
with_alias = sys.getrefcount(value)
del alias
after_delete = sys.getrefcount(value)
assert with_alias == baseline + 1
assert after_delete == baseline
print("One alias adds one reference:", with_alias == baseline + 1)The assertion checks a difference, not a universal baseline. A fresh list avoids the special treatment of common cached objects. This prints One alias adds one reference: True on the tested build. Run it as a script rather than adding debugger watches or interactive history references to the experiment.
Immortal objects can have artificially large counts that do not represent ordinary ownership. The getrefcount documentation calls out this caveat. The C API reference-counting documentation also distinguishes build-specific behavior. An experiment on a GIL build should not be silently generalized to free-threaded CPython or another interpreter.
I would not put a reference-count assertion into business logic. Adding tracing, wrapping a function, or changing the surrounding evaluation can add references without changing the intended result. Counts help investigate a small ownership hypothesis. They are poor identifiers, synchronization primitives, or substitutes for an explicit lifecycle protocol.
Consider two objects that refer to each other. Initially, local names also reach them. After those local names are removed, each object still has a reference from the other. Counting those internal edges alone cannot establish whether useful program state can reach the pair.
The cycle is unreachable if no live external owner reaches either member. A cyclic garbage collector can identify this condition and reclaim eligible objects even though the internal references never individually fell to zero first. This complements reference counting. It does not make ordinary containers stop owning their contents.
The next script temporarily disables automatic cyclic collection so the intermediate state is observable. It then requests collection explicitly. Run it in a fresh process, as the accompanying checks do, because changing a process-wide collector setting inside an unrelated application would complicate the experiment.
import gc
import weakref
class Node:
pass
was_enabled = gc.isenabled()
gc.disable() # Make the observation deterministic in this small experiment.
try:
left = Node()
right = Node()
left.peer = right
right.peer = left
left_ref = weakref.ref(left)
right_ref = weakref.ref(right)
del left, right
assert left_ref() is not None
assert right_ref() is not None
gc.collect() # Ignore the process-wide collection total.
assert left_ref() is None
assert right_ref() is None
print("Unreachable cycle reclaimed")
finally:
if was_enabled:
gc.enable()The assertions answer the question we care about: both weak references stop resolving after collection. The returned collection total is intentionally ignored. Other objects in the process can also be collected, and the demonstration should not fail because unrelated runtime activity changes a total.
The finally block restores automatic collection if it was previously enabled. The example uses simple objects with no finalizers and no resurrection behavior. More elaborate object lifecycles can complicate cleanup, so this is a demonstration of an unreachable cycle, not a claim that every possible cycle has the same finalization sequence.
The weakref documentation describes weak references as observers that do not themselves keep the referent alive. They are useful here because storing an ordinary alias merely to inspect the object would change the lifetime under investigation.
Calling a weak reference returns the live object or None. If you assign the live result to another name, that name becomes an ordinary strong owner. This is correct and often necessary when using a weakly held object safely, but it changes a lifetime experiment.
import gc
import weakref
class Record:
pass
record = Record()
observer = weakref.ref(record)
held = observer() # This name now holds a strong reference.
del record
gc.collect()
assert observer() is held
del held
gc.collect()
assert observer() is NoneThe first collection cannot remove the object because held still reaches it. Releasing held allows the final assertion to pass. Likewise, a debugger’s variable panel, an interactive console’s last-result history, or a list of inspected objects can alter the graph being observed.
This is why I prefer a small script with assertions over a screenshot of a reference-count session. The script makes the observation’s own references visible. It is easier to repeat, review, and run on the exact interpreter used by the application.
In the standard GIL-enabled CPython build, Python 3.14.0 through 3.14.4 removed generation 1; gc.collect(1) performed an incremental collection step, and threshold2 was ignored. The documented 3.14.5 changes reintroduced generation 1, made collect(1) collect the middle generation, and restored threshold2 behavior to match Python 3.13. See the change notes for get_objects, collect, and set_threshold.
Those generation details describe the GIL-enabled collector. CPython 3.14’s free-threaded build uses a non-generational collector that examines the whole heap on each collection, as described in the collector design documentation. A free-threaded binary can also run with its GIL enabled; that runtime setting does not make it the standard build. Check the build configuration before transferring collector advice between deployments.
The examples were executed on 3.14.4. They use gc.collect() without a generation argument and do not test 3.14.5 scheduling or thresholds. The online 3.14 documentation incorporates later patch changes, so its present-tense explanation should not be applied unqualified to every 3.14 installation.
gc.get_objects() is a debugging interface, not a list of every allocation in the process. It reports collector-tracked objects, and the returned list itself holds references to its contents. I would record the full interpreter version and build before interpreting generation-specific diagnostics or changing collection settings.
For application design, the durable lesson is independent of these scheduling details: an unreachable cycle may need cyclic collection, while reachable state remains live. Default thresholds should not be changed merely because a simplified tutorial used a particular generation count. A collector tuning decision needs workload evidence and the documentation for the deployed patch release.
Object reclamation releases storage to the relevant allocator. It does not necessarily return the same number of bytes to the operating system immediately. CPython manages a private heap and uses allocator layers; the memory management documentation describes those layers and the build-specific allocators.
Resident set size, or RSS, measures process-resident memory pages. It can include Python objects, allocator bookkeeping, resident allocator pages retained for reuse, extension allocations, stacks, and other process state. Address space that is merely reserved is not necessarily resident. A drop in the number of live objects therefore need not produce a matching drop in RSS.
For example, a batch may create many short-lived objects, release them, and leave allocator storage ready for a later batch. Another workload may retain one small wrapper that keeps a much larger graph alive. These are different explanations for similar-looking process charts. Neither can be diagnosed by assuming that gc.collect() is an operating-system memory flush.
A useful experiment repeats a representative unit of work, reaches the same idle point, and compares live allocations and process memory separately. Include the interpreter version, extension stack, input size, and concurrency. If memory stabilizes after warm-up, investigate reuse before declaring an unbounded leak. If retained state grows after each equivalent cycle, trace its owners.
The standard-library tracemalloc module can compare snapshots of traced Python allocations. Start tracing early enough to capture the allocations of interest. It is one source of evidence, not a complete explanation of all native memory or RSS. A native extension may require additional tooling.
A file descriptor, socket, transaction, or lock has a useful lifetime that should follow application control flow. Waiting for its wrapper object to become unreachable makes that lifetime indirect. A lingering alias, a traceback, or a different Python implementation can delay the cleanup you expected.
A context manager provides an explicit exit boundary. The following example uses a temporary directory so it is safe to run without preparing a filesystem path. The file is closed even though the body raises an exception.
from pathlib import Path
from tempfile import TemporaryDirectory
with TemporaryDirectory() as directory:
path = Path(directory) / "result.txt"
stream = None
try:
with path.open("w", encoding="utf-8") as stream:
stream.write("started\n")
raise RuntimeError("Batch failed")
except RuntimeError:
assert stream is not None and stream.closed
assert path.read_text(encoding="utf-8") == "started\n"The name stream still exists after the block and still refers to the file object. The underlying file is closed because the context manager exited, not because the wrapper disappeared. The with statement specification describes the context-management protocol.
For a database client or another library-managed resource, follow that library’s cleanup contract. A context manager may close a resource, return it to a pool, or commit or roll back a transaction. Those meanings are not interchangeable. Read the concrete API, and keep the resource boundary close to the work that requires it.
Here is a smaller version of a common service mistake: a history list retains every completed batch. There is no cycle for the collector to discover. The result is reachable by design, even after the processing function returns.
import gc
import weakref
class Batch:
pass
history = []
def process_batch():
batch = Batch()
history.append(batch)
return weakref.ref(batch)
observer = process_batch()
gc.collect()
assert observer() is history[0]
history.clear()
gc.collect()
assert observer() is NoneClearing the collection removes the owner. Repeated collection before that point cannot fix the retention policy. In a real service, the right change might be a bounded history, a compact summary, an eviction policy, or a completed-task cleanup path. The collector cannot decide which business records should expire.
I start an investigation by describing the intended owner and release event. A request owns a payload until its response completes. A cache owns an entry until eviction. A queue owns a job until removal. Then I check which reference path survives that event. This turns “Python did not free memory” into a testable lifecycle question.
Reference counting explains many prompt deallocations in CPython. Cyclic collection handles eligible unreachable cycles. Allocator behavior explains why reclaimed objects and RSS can move differently. Explicit cleanup handles resources whose release must happen at a known boundary. Keeping these mechanisms separate makes memory investigations much less speculative.
The next article, CPython Optimizations: Interning and Constant Folding, examines another reason objects may be shared. That sharing can reduce repeated work, but it should never determine whether two application values compare equal.

Senior Technical Architect at HCLTech · working with Leonteq Security AG
I have 10+ years of experience building distributed systems and fintech platforms. I write about practical, non-obvious engineering details that official documentation often skips.
Scala, JVM, AI, and backend systems. I send practical articles when there is something worth reading.
Join the newsletter