Garbage Collection & Reference Counting Interview Questions & Answers
Python interview questions on memory management — reference counting, the cyclic garbage collector and reference cycles, sys.getrefcount, weak references, __del__ pitfalls, and generational GC.
Every CPython object carries a reference count — the number of references
pointing at it. The count goes up when you bind a new name, append it to a
container, or pass it to a function, and down when a name is reassigned, goes
out of scope, or is del'd. When the count hits zero, the object is
immediately deallocated.
a = [1, 2, 3] # the list's refcount is 1
b = a # 2 — another reference
del a # 1 — still alive (b references it)
b = None # 0 — list is freed right away
This is deterministic and prompt — memory is reclaimed the instant the last reference disappears, not at some later sweep.
Why it matters: reference counting handles the vast majority of garbage, but it can't free reference cycles on its own, which is why CPython adds a second collector.
Reference counting fails on reference cycles — objects that refer to each other,
so their counts never reach zero even when nothing outside the cycle reaches them.
Without help they'd leak forever. CPython's cyclic garbage collector (the gc
module) periodically finds and frees these unreachable cycles.
import gc
a, b = {}, {}
a['b'] = b # a -> b
b['a'] = a # b -> a (a cycle)
del a, b # both refcounts stay at 1 — NOT freed by refcounting
gc.collect() # the cyclic collector reclaims the unreachable cycle
The collector works by tracking container objects (lists, dicts, instances) and detecting groups whose only references are internal to the group.
Rule of thumb: you rarely call gc.collect() manually — it runs automatically —
but it's worth knowing cycles are collected later, not immediately like refcounts.
sys.getrefcount(obj) returns the object's current reference count. It almost
always reads one higher than you expect because passing the object as the
argument creates a temporary reference for the duration of the call.
import sys
x = []
sys.getrefcount(x) # 2 — one for `x`, one for the argument itself
y = x
sys.getrefcount(x) # 3 — `x`, `y`, and the argument
Small ints and interned strings show huge counts because they're cached singletons shared across the whole interpreter.
Why it matters: it's a debugging/teaching tool for understanding aliasing and leaks — just remember to subtract one for the call's own reference, and don't rely on exact values across implementations.
A weak reference (weakref module) points to an object without increasing its
reference count, so it does not keep the object alive. When the last strong
reference is gone, the object is collected and the weak reference returns None.
import weakref
class Node: pass
n = Node()
r = weakref.ref(n) # weak — doesn't count toward n's lifetime
r() # <Node object> — still alive
del n # last strong ref gone -> object freed
r() # None — referent is dead
They're ideal for caches and observer/parent-child back-references where you
want to reference an object but not prevent its cleanup — weakref.WeakValueDictionary
is a common cache type.
Rule of thumb: use a weak reference to break a cycle or avoid a memory leak when one object should not own the lifetime of another.
__del__ is a finalizer called when an object is about to be destroyed — but
its timing is not guaranteed. It runs only when the refcount hits zero (or later,
via the cyclic collector), so you can't rely on when, or even whether, it
runs. Note del obj only decrements the refcount; it doesn't directly call __del__.
class Resource:
def __del__(self):
print("cleaning up") # may run late, or not at all on interpreter exit
r = Resource()
r2 = r
del r # nothing happens — r2 still references it
del r2 # NOW refcount is 0 -> __del__ runs
Objects in a reference cycle that define __del__ historically couldn't be
collected at all (improved in Python 3.4+ via PEP 442), and exceptions raised inside
__del__ are ignored.
Rule of thumb: don't use __del__ for important cleanup — use context managers
(with) or try/finally, which give deterministic, explicit release.
The cyclic collector is generational: it sorts tracked objects into three generations (0, 1, 2) based on how many collections they've survived. New objects start in generation 0, which is scanned most frequently; survivors are promoted to older generations that are scanned less often.
The idea is the weak generational hypothesis: most objects die young, so it's efficient to focus collection effort on the youngest generation and rarely re-scan long-lived objects.
import gc
gc.get_count() # (gen0, gen1, gen2) allocation counters
gc.get_threshold() # (700, 10, 10) — triggers per generation
gc.collect(0) # collect only generation 0 (cheap, frequent)
A generation-0 collection is fast and common; full (generation-2) collections are rarer and more expensive.
Rule of thumb: this is mostly automatic, but for latency-sensitive code you can tune
thresholds, call gc.collect() strategically, or gc.disable() it if you manage
lifetimes carefully.
Disabling the cyclic GC with gc.disable() can reduce latency/pauses in
short-lived batch jobs or programs that create huge numbers of objects without
cycles. Reference counting still frees most garbage; you just skip the periodic cycle
scans.
import gc
gc.disable() # no cyclic collection pauses
try:
run_batch_job() # millions of temp objects, no cycles
finally:
gc.enable()
Rule of thumb: disabling GC helps only when you avoid reference cycles and care about pause time (or startup); otherwise you risk leaking cycles — re-enable when done.
The GC only tracks container objects that can hold references to others (lists, dicts, sets, instances, tuples-of-containers). Atomic objects like ints, strings, and floats can't form cycles, so they're managed purely by reference counting and never tracked.
import gc
gc.is_tracked([]) # True — a container
gc.is_tracked(42) # False — atomic
gc.is_tracked("hi") # False
gc.is_tracked((1, 2)) # False — tuple of atomics may be untracked
Rule of thumb: only containers participate in cyclic GC; scalars rely on refcounting alone, which is why they're freed instantly at refcount zero.
C extensions must manually Py_INCREF/Py_DECREF. Forgetting an incref can free
an object still in use (crash/use-after-free); forgetting a decref leaks memory.
These are a classic source of CPython extension bugs.
// missing Py_INCREF -> object freed while still referenced -> segfault
// missing Py_DECREF -> refcount never hits 0 -> memory leak
Py_INCREF(obj); // claim a reference
// ... use obj ...
Py_DECREF(obj); // release it
Rule of thumb: in C extensions, balance every incref with a decref; in pure Python you never manage counts manually — the interpreter does it for you.
The gc module exposes introspection: gc.get_objects() lists tracked objects,
gc.get_referrers/get_referents walk the reference graph, and gc.set_debug
reports uncollectable objects — useful for hunting leaks and lingering cycles.
import gc
gc.set_debug(gc.DEBUG_LEAK) # report objects that can't be collected
gc.collect() # prints leaked/uncollectable objects
gc.garbage # list of uncollectable objects (e.g. cycles)
len(gc.get_objects()) # total tracked object count
Rule of thumb: use gc.get_referrers/gc.garbage to find what keeps an object
alive; combine with tracemalloc to locate allocation sites of leaks.
tracemalloc traces where memory was allocated, letting you snapshot usage and
diff snapshots to find leaks and memory hot spots by source line — far more precise
than guessing from gc.
import tracemalloc
tracemalloc.start()
snap1 = tracemalloc.take_snapshot()
run_workload()
snap2 = tracemalloc.take_snapshot()
for stat in snap2.compare_to(snap1, "lineno")[:5]:
print(stat) # top growth by file:line
Rule of thumb: use tracemalloc snapshots/diffs to pinpoint the code lines
responsible for growing memory, then fix the retained references.
Every object carries per-object overhead: a refcount, a type pointer, and (for
containers) extra bookkeeping. So an int is ~28 bytes, an empty list ~56 bytes —
the value itself is tiny but the object header dominates.
import sys
sys.getsizeof(0) # ~24-28 bytes for a small int
sys.getsizeof([]) # ~56 bytes empty list
sys.getsizeof("") # ~49 bytes empty str
# __slots__ or arrays/numpy cut overhead for many small items
Rule of thumb: Python objects have heavy headers — for millions of small items use
__slots__, array, or NumPy to avoid per-object overhead.
del name removes the binding and decrements the object's refcount — it does
not directly call __del__ or free memory unless that was the last reference.
del obj.attr and del lst[i] remove an attribute or element.
a = [1, 2, 3]
b = a
del a # removes name 'a'; list still alive via b (refcount 1)
del b # refcount 0 -> list freed now
d = {"k": 1}
del d["k"] # removes the key, not the dict
Rule of thumb: del unbinds a name (or removes an item/attr); deallocation happens
only when that drops the refcount to zero.
weakref.ref(obj, callback) and weakref.finalize(obj, func, ...) register a
callback invoked when the object is garbage-collected — a safe alternative to
__del__ for cleanup tied to an object's death.
import weakref
class Conn: pass
c = Conn()
def on_death(ref):
print("connection collected")
r = weakref.ref(c, on_death)
finalizer = weakref.finalize(c, print, "cleanup ran")
del c # triggers on_death and the finalizer
Rule of thumb: prefer weakref.finalize over __del__ for cleanup hooks — it's
explicit, runs reliably, and avoids resurrecting objects in finalizers.
CPython manages small objects through its own pymalloc arenas/pools. Freed memory often returns to these pools for reuse, not to the OS, so process RSS can stay high after a spike. Large allocations are more likely to be returned.
# after creating and deleting a huge list, process memory may not shrink:
big = [object() for _ in range(10_000_000)]
del big # memory returns to pymalloc pools, not necessarily the OS
Rule of thumb: don't expect RSS to drop after freeing many small objects — to truly reclaim memory for a one-off spike, isolate the work in a subprocess that exits.
More Memory & Internals interview questions
More ways to practice
The self-quiz is live. Join our channel for updates, new content & tech tips.