Reference Counting#
CPython manages most object lifetimes with reference counting: every object tracks how many references point to it. When the count drops to zero, the object is destroyed immediately. A separate cycle detector (gc module) handles reference cycles that refcount alone cannot break. Understanding both layers is essential for debugging memory leaks, writing correct __del__ methods, and reasoning about performance.
How to use this page
Pair with Global Interpreter Lock for allocator/thread interactions and Classes and OOP Basics for instance lifecycle. Use gc and weakref in production diagnostics before reaching for external profilers.
At a glance
| Track | Python Advanced → Memory Management and Performance |
| Sections | 10 major topics |
| Outline | Use the right-hand TOC to jump |
Topics: How reference counting works · Immediate deallocation example · Reference cycles and the cycle collector · __del__ pitfalls · Interning and small integer caching · weakref — references without keeping objects alive · Memory views and buffer sharing · Performance implications · … (+2 more)
- How reference counting works
- Immediate deallocation example
- Reference cycles and the cycle collector
__del__pitfalls- Interning and small integer caching
weakref— references without keeping objects alive- Memory views and buffer sharing
- Performance implications
- Debugging memory issues
- C extensions and refcounting
How reference counting works#
Every Python object has a header with ob_refcnt. Operations that create or destroy references adjust the count:
| Operation | Effect on refcount |
|---|---|
x = obj |
obj refcount +1 |
del x |
obj refcount −1 |
Pass obj to a function |
+1 on entry, −1 on return |
Store in container lst.append(obj) |
+1 |
| Remove from container | −1 |
Function returns obj |
+1 for the returned reference |
import sys
class Node:
def __init__(self, value: int) -> None:
self.value = value
a = Node(1)
print(sys.getrefcount(a)) # typically 2: `a` + temporary arg to getrefcount
getrefcount adds a temporary reference
The argument to sys.getrefcount(obj) itself increments the count by one during the call. Expect count ≥ 1 for any live object.
When refcount reaches 0, CPython calls the object's destructor (tp_dealloc), which may invoke __del__ if defined, then frees memory.
Immediate deallocation example#
class Resource:
def __init__(self, name: str) -> None:
self.name = name
def __del__(self) -> None:
print(f"destroyed: {self.name}")
def scope_demo() -> None:
r = Resource("temp") # refcount 1
# r goes out of scope → refcount 0 → __del__ runs
scope_demo() # prints: destroyed: temp
Destruction is deterministic for acyclic objects — unlike Java's GC, you can often predict when __del__ fires (but should not rely on it for critical cleanup; use with and context managers).
Reference cycles and the cycle collector#
Refcounting cannot reclaim cycles:
class Linked:
def __init__(self) -> None:
self.ref: Linked | None = None
a = Linked()
b = Linked()
a.ref = b
b.ref = a
del a, b # neither refcount reaches 0 — memory leak without gc
CPython's generational garbage collector (gc module) periodically scans for unreachable cycles.
import gc
gc.collect() # force a collection cycle
print(gc.get_count()) # objects per generation (0, 1, 2)
gc.set_threshold(700, 10, 10) # tune collection frequency (advanced)
| Generation | Typical contents | Collection frequency |
|---|---|---|
| 0 | New objects | Most often |
| 1 | Survived one collection | Less often |
| 2 | Long-lived objects | Least often |
import gc
class Cycle:
def __init__(self) -> None:
self.loop: Cycle | None = None
c = Cycle()
c.loop = c
print(gc.get_referrers(c)) # debugging: who points to c?
Disable gc only with extreme care
gc.disable() avoids collection pauses in latency-sensitive C extensions but risks unbounded growth if your code creates cycles. Re-enable in tests.
__del__ pitfalls#
class Bad:
def __del__(self) -> None:
open("log.txt", "a").write("bye\n") # fragile — interpreter may be shutting down
| Problem | Why |
|---|---|
| Order undefined | Other globals may already be None |
| Resurrection | __del__ can recreate references, reviving the object |
Cycles with __del__ |
Objects in cycles with __del__ may become uncollectable |
import gc
class Leaky:
def __del__(self) -> None:
pass
a = Leaky()
b = Leaky()
a.ref = b
b.ref = a
del a, b
print(gc.garbage) # may list uncollectable cycle if __del__ present
Prefer context managers (with) and explicit .close() over __del__ for resource cleanup. See File Handling.
Interning and small integer caching#
CPython interns some immutable objects for performance:
a = "hello"
b = "hello"
print(a is b) # True — same object (string interning, compile-time literals)
x = 256
y = 256
print(x is y) # True — small int cache (−5 to 256)
x = 257
y = 257
print(x is y) # May be False — separate objects outside cache
| Cached range | Type |
|---|---|
| −5 to 256 | int |
| Single-character strings (some cases) | str |
True, False, None |
singletons |
This affects is vs == interview questions but not refcount correctness — interned objects simply have higher refcounts shared across the runtime.
weakref — references without keeping objects alive#
import weakref
class Cache:
def __init__(self) -> None:
self._data: dict[str, weakref.ref] = {}
def get(self, key: str, factory):
ref = self._data.get(key)
obj = ref() if ref else None
if obj is None:
obj = factory()
self._data[key] = weakref.ref(obj)
return obj
| API | Use case |
|---|---|
weakref.ref(obj) |
Callable proxy; returns None when obj dies |
weakref.WeakValueDictionary |
Cache that evicts dead values |
weakref.WeakSet |
Set of objects without preventing collection |
weakref.finalize(obj, callback) |
Preferred over __del__ for cleanup |
import weakref
def cleanup(name: str) -> None:
print(f"finalized: {name}")
obj = object()
weakref.finalize(obj, cleanup, "my-obj")
del obj # cleanup runs when obj is collected
Memory views and buffer sharing#
Objects supporting the buffer protocol (bytes, array, NumPy arrays) can share memory without copying:
data = bytearray(b"hello world")
mv = memoryview(data)
mv[0:5] = b"HELLO"
print(data) # bytearray(b'HELLO world') — same underlying buffer
Slices of memoryview increment refcounts on the underlying object until the view is released.
Performance implications#
| Factor | Impact |
|---|---|
| High churn of short-lived objects | Frequent inc/dec overhead; generational gc helps |
| Large object graphs | Traversing refs during collection pauses |
__slots__ |
Fewer per-instance dicts → lower memory |
__dict__ on every instance |
Extra dict per object → higher memory |
class Slim:
__slots__ = ("x", "y")
def __init__(self, x: int, y: int) -> None:
self.x = x
self.y = y
Use __slots__ when you have millions of homogeneous instances and do not need dynamic attributes.
Debugging memory issues#
import tracemalloc
tracemalloc.start()
# ... run code ...
snapshot = tracemalloc.take_snapshot()
for stat in snapshot.statistics("lineno")[:10]:
print(stat)
tracemalloc.stop()
| Tool | Purpose |
|---|---|
tracemalloc |
Track allocations by line |
gc.get_objects() |
Enumerate all tracked objects (heavy) |
objgraph (third-party) |
Visualize reference graphs |
sys.getsizeof() |
Shallow size (not recursive) |
import sys
d = {"a": [1, 2, 3]}
print(sys.getsizeof(d)) # dict overhead only
# Does not include size of keys/values — use recursive helpers for totals
C extensions and refcounting#
C API functions use Py_INCREF / Py_DECREF. Incorrect balancing causes leaks or premature frees (segfaults). Python code rarely touches this, but extension bugs manifest as "random" crashes under load.
With the GIL (default CPython), refcount updates are not atomic across threads — another reason shared mutable objects across threads need care. See Global Interpreter Lock.
Interview traps (quick reference)#
| Trap | What goes wrong | Safe approach |
|---|---|---|
Relying on __del__ for cleanup |
Missed on cycles, shutdown races | Context managers, weakref.finalize |
Assuming del frees memory immediately |
Only drops a reference | Understand refcount semantics |
is for value comparison |
Wrong for integers > 256, constructed strings | Use == for equality |
| Ignoring reference cycles | Slow memory leak | Break cycles with weakref or redesign |
getrefcount off-by-one |
Misread live references | Account for temporary ref |
sys.getsizeof for total footprint |
Reports shallow size only | Recursive measurement or profiler |
Mental model checklist#
- What happens to refcount when you pass an object to a function?
- Why can't pure refcounting collect
a.ref = b; b.ref = a? - When is
__del__a bad choice for closing files or sockets? - How does
weakref.ref(obj)()behave afterobjis collected? - What is the small-integer cache range in CPython?
- What is the difference between
gc.collect()and refcount reaching zero?
What's next#
| Topic | Page |
|---|---|
| GIL and threading | Global Interpreter Lock |
| Resource cleanup patterns | File Handling |
| Context managers | Error Handling |
| Advanced file buffering | File IO |