Skip to content

Garbage Collection & Reference Counting Interview Questions & Answers

15 questions Updated 2026-06-18 Share:

Python interview questions on memory management — reference counting, the cyclic garbage collector and reference cycles, sys.getrefcount, weak references, __del__ pitfalls, and generational GC.

Read the in-depth guidePython Garbage Collection Explained — Reference Counting, Cycle Detection, and the gc Module(opens in new tab)
15 of 15

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 ways to practice

The self-quiz is live. Join our channel for updates, new content & tech tips.

Join our WhatsApp Channel