AlgoMaster Logo

Reference Counting

Low Priority14 min readUpdated September 21, 2026
Listen to this chapter
Unlock Audio

CPython's primary memory manager is reference counting: every object carries a counter, and when the counter drops to zero, the object is freed immediately. This is the mechanism that makes Python deterministic: a Cart goes out of scope, its memory is gone before the next line runs. This lesson covers how the counter is maintained, what changes it on each Python operation, why sys.getrefcount always seems off by one, and how this design interacts with the GIL and with non-CPython interpreters.

What the Counter Holds

Every Python object on the heap has a PyObject header. The two fields that matter for this lesson are ob_refcnt (an integer counting how many references point at this object) and ob_type (a pointer to the object's class). The rest of the header is implementation-specific.

For product = Product("headphones", 79.99), CPython does roughly three things:

  1. Allocate memory for a new Product instance.
  2. Initialize its header with ob_refcnt = 1 and ob_type = Product.
  3. Bind the name product to that address.

The 1 comes from the assignment itself. The name product is one reference. If nothing else takes a reference before product goes out of scope, the count returns to zero and the memory is freed.

The name product is the only thing keeping the counter above zero. The rest of the object is the data the class needs.

Inspecting the Count

sys.getrefcount(obj) returns the current refcount of obj, including the temporary reference created by passing obj into getrefcount itself.

The first line prints 2, not 1, because passing p to getrefcount adds a temporary reference inside the call frame. This is the "off by one" behavior. What matters is the direction:

  • backup = p adds one (a new name).
  • cart.append(p) adds one (the list now holds it).
  • del backup subtracts one.
  • cart.clear() subtracts one (the list dropped it).

Absolute numbers from getrefcount are implementation-specific and depend on what is happening inside the runtime. Use the deltas, not the values.

sys.getrefcount is cheap but the temporary reference is real. Avoid using it for fine-grained "did the refcount go up by exactly 1" assertions in tests; the temporary makes those assertions unreliable.

What Changes the Count

Every operation that creates or destroys a reference changes the count. The list looks long but the rule is the same in every case: any time another piece of the program can reach this object, the count goes up; when it can no longer reach it, the count goes down.

OperationEffect on the target's refcount
x = obj (assignment)+1
del x-1
Variable goes out of scope-1
container.append(obj)+1
container.pop()-1
container.clear()-1 per element
func(obj) (passing as argument)+1 during the call, -1 after return
return obj from a functionunchanged (caller takes over the reference)
obj.attr (attribute read)+1 temporarily, -1 right after
globals()['name'] = obj+1
Closure captures obj+1 (closure cell holds it)
Module imports re-bind a name-1 on the old object, +1 on the new

A small example brings the table to life:

Each piece of the program that can reach o contributes one to the count. Removing any of them takes one away.

The show function is doing extra work here. Calling show(o) triggers an argument binding inside show that creates one new reference, then sys.getrefcount(obj) runs and counts it. The returned number is the count from inside show, which is always one higher than the count an outside observer would see. This is the "off by one" again from a different angle.

Function Arguments and Returns

Arguments are passed by reference in Python. A call func(obj) does not copy obj; it binds the parameter name inside func to the same object. That binding is a new reference, so the count goes up by one for the duration of the call.

Walking through the numbers: before the call, the count from outside is 1 + 1 = 2 (the name cart plus getrefcount's temporary). Inside fill, three references exist: the outer cart name, the inner cart parameter, and getrefcount's temporary. After the call returns, the parameter binding is gone, and the count is back to 2.

Returning an object does not change the count by itself. The function's local reference is released, but the caller's name takes over, so the net effect is zero.

Inside make_order, the count is 1 (local o) + 1 (getrefcount temporary) = 2. After the return, the local o is gone but result now holds the same reference, so the count is again 1 (result) + 1 (getrefcount temporary) = 2.

Containers Hold References

Putting an object into a container counts as a new reference. The container is a regular Python object whose internal storage points at the items it holds.

Each list slot contributes one. Each dict entry contributes one. pop() removes the last list slot, dropping the count by one. Removing the dict entries explicitly (del wishlist['a']) would also drop the count.

When a container is itself freed, every element it holds loses one reference. This cascades: if dropping the container drops an element's count to zero, that element is freed too, which can drop further counts to zero, and so on. The cascade is bounded but deep cascades can produce noticeable pauses when a container with many large children goes away all at once.

Clearing a large container is O(n) in refcount decrements. A dict of a million Order objects, freed in one go, performs a million decrements. Profilers occasionally show these as "GC pauses" even though no cycle collection is involved.

Why the Object Is Freed Immediately

When a refcount hits zero, CPython does not wait. The object is freed right then, before the next bytecode instruction. The class's __del__ (if defined) runs as part of the cleanup. Any references the object held to other objects are released, possibly triggering further frees.

The Order(101) instance has exactly one reference: the local o. When process returns, the frame is torn down and o goes away. The refcount drops to zero. The cleanup runs. The interpreter then executes the next line, which prints "after process". The order of these three messages is fixed, not a coincidence.

This is the deterministic-cleanup property of reference counting. Context managers, file handles, and locks all rely on it. For example:

This relies on the fact that the file's __exit__ runs synchronously when the with block ends. Reference counting is what makes the __del__ of a temporary file or the cleanup of a wrapping object reliable.

For non-cyclic objects, the lifecycle is exactly this. No collector, no delay.

What Reference Counting Cannot Do

The deterministic-free property is what makes reference counting attractive. The fatal limitation is that it cannot handle cycles. Two objects that reference each other keep each other's refcount above zero, even when nothing outside the cycle can reach them.

Neither the customer nor the order is freed. Refcount on Customer: one (the order's self.customer). Refcount on Order: one (the customer's orders list). When make returns, the local c goes away, but the cycle still exists. Each side keeps the other alive.

The cycle collector addresses this case. Reference counting handles most frees on its own. The cycle collector handles the rest. The two together cover the whole story.

Reference Counting and the GIL

CPython's reference-count operations have to be atomic. If two threads incremented the same counter at the same time without synchronization, the result would be torn updates and a drifting count, eventually freeing a live object or leaking a dead one.

In single-threaded code this is not a concern. In multi-threaded code, CPython solves it with the Global Interpreter Lock (GIL). Only one thread can execute Python bytecode at a time, which means only one thread can modify a refcount at a time. Every increment and decrement happens under the lock, and the lock is what makes the operation atomic from the runtime's point of view.

This is one of the main reasons the GIL has been hard to remove from CPython. The naive fix (use an atomic compare-and-swap on every refcount op) makes single-threaded performance much worse: atomic ops on modern CPUs are 10 to 100 times slower than non-atomic ones, and refcount operations happen on essentially every Python expression. Decades of work has gone into figuring out how to remove the GIL without paying that cost.

The free-threaded Python build (PEP 703, available in Python 3.13 and later as an experimental option) makes refcounts atomic for shared objects but uses biased reference counting and immortal objects to avoid the atomic-op cost in most cases. The details are subtle. For this lesson, the takeaway is that "refcount must be atomic" is the foundational reason the GIL exists, and removing it forces a redesign of how ob_refcnt is updated.

Atomic refcount operations in free-threaded Python are slower than the GIL-protected non-atomic ops in regular CPython. Single-threaded code may run 15 to 25 percent slower on free-threaded builds. Multi-threaded code that previously serialized on the GIL can run much faster. Which one wins depends on the workload.

Other Pythons Do Not Use Refcounts

Reference counting is a CPython implementation detail. The Python language does not require it, and several alternative interpreters take a different approach.

PyPy uses a tracing garbage collector instead of reference counting. Objects are allocated cheaply on a young-object heap, and a generational tracer reclaims them in bulk. There is no ob_refcnt field, no atomic counter, no deterministic-free guarantee.

Jython runs Python on the JVM and uses the JVM's garbage collector. Same idea: tracing, not refcounting.

IronPython runs on .NET's CLR and uses the CLR's garbage collector.

MicroPython is small enough that it can ship either a refcount or a tracing collector depending on the target hardware.

The visible difference is that __del__ is no longer deterministic on these interpreters. A file that goes unclosed in CPython gets closed when the file object's refcount hits zero. On PyPy, the file might not be closed until the next major GC cycle runs, which could be much later. This is why "always use a context manager" is the right advice for resource cleanup, even though refcounting in CPython would make a bare open() work most of the time.

Sticking to context managers makes the code portable across interpreters and predictable across Python versions.

Performance Characteristics

Refcounting has predictable, fine-grained overhead. Each new reference is an integer increment, each dropped reference is an integer decrement plus a check for zero. The numbers are small but they happen everywhere: every function call, every list append, every dict lookup that yields an object that is then used.

The trade-off compared to tracing GC:

AspectRefcounting (CPython)Tracing GC (PyPy)
Per-operation costSmall but constant overhead on every reference opAlmost zero on creation, periodic cost on collection
Pause behaviorNo pauses for non-cyclic garbage; cycles collected separatelyPeriodic stop-the-world or incremental pauses
DeterminismImmediate, deterministic __del__ for non-cyclic objectsDelayed, non-deterministic finalization
Multi-threadingGIL required for atomicity (in standard build)No equivalent constraint
PredictabilityHigh: same code, same lifecycle, every runLower: depends on heap state

For long-running services with mostly short-lived objects, refcounting wins on predictability (no surprise pauses) and loses on throughput (every reference op pays). For batch jobs that allocate huge amounts of short-lived garbage, tracing wins on throughput and may produce noticeable pauses.

The deeper point is that either style produces correct, reasonably fast Python. The reasons to care show up in two places: when code depends on deterministic cleanup (refcounting helps), and when removing the GIL is the goal (refcounting gets in the way).

A Practical Example: When the Count Matters

A common debugging task is "is anything still holding this object?" sys.getrefcount plus knowledge of "subtract one for the call itself" gives a quick answer.

The "before release" count of 2 is the local s plus getrefcount's temporary. After pool.release(s), the count rises to 3 because the pool's idle list now holds the session too. The truly_release call does not change anything, which is the bug: the function is supposed to drop the pool's reference but does not. The count staying at 3 is the evidence.

In practice, scattered sys.getrefcount calls are not the right approach. The useful pattern is: when an object is sticking around longer than expected, use gc.get_referrers(obj) to identify what is holding it, and use the refcount as a quick "did anything change" indicator before and after a suspected fix.

Quiz

Reference Counting Quiz

10 quizzes