Python Memory Management: Reference Counting and Garbage Collection

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.

What keeps an object alive?

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.

Strong references and a weak observer of one payloadRead strong owners first. A local name and a cache entry each hold an ordinary strong reference to the payload. A dashed weak-reference edge only observes it. Deleting the local binding leaves the cache's strong reference intact.Any remaining strong owner can keep the payload alivelocal payloadcache['current']weak observerPayload objectStill strongly reachableWeak edge: observes, does not owndel payload removes one binding. The cache remains an owner.
Strong references and a weak observer of one payloadRead strong owners first. A local name and a cache entry each hold an ordinary strong reference to the payload. A dashed weak-reference edge only observes it. Deleting the local binding leaves the cache's strong reference intact.Strong owners keep objects alivelocal namecache entryPayload objectReachable through either strong edgeweak observerDashed edge: does not keep it alive.Deleting the local name leaves the cache.
Solid arrows are strong references. Removing one owner is insufficient while another strong owner 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 removes a binding or a container entry

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.

Reference counting in a controlled experiment

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.

A positive count does not imply reachability

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.

An unreachable cycle requires more than simple reference countingThe external name bindings have already been removed. Node A refers to Node B, and Node B refers to Node A. Each has an internal reference, but no live external strong owner can reach the pair. Cyclic garbage collection can reclaim this eligible unreachable graph.Internal references can survive without an external ownerState after del left, rightNode Apeer points to BNode Bpeer points to AA.peerB.peerNo live external strong path enters this pair.Cyclic collection can reclaim the unreachable graph.
An unreachable cycle requires more than simple reference countingThe external name bindings have already been removed. Node A refers to Node B, and Node B refers to Node A. Each has an internal reference, but no live external strong owner can reach the pair. Cyclic garbage collection can reclaim this eligible unreachable graph.An unreachable reference cycleAfter del left, rightNode ANode BA.peerB.peerNo external strong path enters the pair.A collector can identify this whole cycle.
A cycle is collectible when it is unreachable from live strong owners. A reachable cycle is still live application state.

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.

Observation can accidentally retain the object

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 None

The 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.

Python 3.14 GC behavior depends on the build and patch release

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.

Reclaimed Python objects and process memory are different

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.

Use with for resource cleanup

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.

Find retention before tuning the collector

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 None

Clearing 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.

Share
Prabhat Kashyap

Prabhat Kashyap

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.

Newsletter

Get new engineering deep dives

Scala, JVM, AI, and backend systems. I send practical articles when there is something worth reading.

Join the newsletter