Python Memory Management
Every Python object lives somewhere in memory, gets there through an allocator, and disappears again once it becomes unreachable — understanding that lifecycle explains a lot of Python's behavior and its occasional leaks.
Python memory management is the system of rules and machinery that decide when an object is created, how long it stays alive, and when its memory is reclaimed. As a language, Python guarantees only that objects live as long as they are reachable and are eventually reclaimed once they are not; the concrete mechanics — reference counting, a cyclic collector, a specialized allocator — are properties of CPython, the reference implementation.
What Is Memory Management, and Who Is Responsible
Unlike C, where you call malloc/free by hand, Python is a managed-memory language: you never explicitly free an object. Instead, the runtime tracks which objects are still reachable from your running program and reclaims the ones that are not. This removes an entire class of manual bugs (use-after-free, double-free) at the cost of some control over exactly when memory is released.
Do not say 'Python automatically deletes variables when they go out of scope.' What actually happens is that a name may stop referencing an object (when it goes out of scope, is reassigned, or del is used on it); the object itself only becomes eligible for reclamation once nothing reachable references it anymore.
Object Allocation
Every object you create — an int, a list, an instance of your own class — needs memory allocated for it somewhere on the heap. CPython implementation detail: CPython uses a specialized memory manager (informally called pymalloc) tuned for many small, short-lived objects, layered on top of the operating system's own allocator, to avoid the overhead of asking the OS for memory on every single object creation.
x = [1, 2, 3] # a new list object is allocated
y = "hello" # a new str object is allocated
class Point:
def __init__(self, x, y):
self.x = x # allocates space to hold this instance's attributes
self.y = y
p = Point(1, 2) # allocates the Point instance itselfReferences and Names
Python's object model is reference-based: a variable is not a box holding a value, it is a name bound to an object living elsewhere in memory. Assignment binds a name to an object; it does not copy the object. Multiple names can reference the same object simultaneously.
a = [1, 2, 3]
b = a # b now names the SAME list object as a
b.append(4)
print(a) # [1, 2, 3, 4] — mutation via b is visible through a
print(a is b) # True — same object identityReachability: The Core Concept
An object is 'reachable' if your program could still get to it by following some chain of references, starting from a root — a global variable, a local variable on the call stack, an attribute of another reachable object, an entry in a reachable container. Once an object is unreachable — no chain of references leads to it from anywhere the running program can access — it can never be used again, and the memory-management system is free to reclaim its memory.
def make_list():
local = [1, 2, 3]
return None # local is never returned or stored anywhere else
make_list()
# the [1, 2, 3] list created inside make_list() is now unreachable —
# nothing in the program can reference it anymoreGarbage Collection: The General Idea
'Garbage collection' is the umbrella term for any mechanism that reclaims memory for unreachable objects automatically. Python the language guarantees garbage collection happens — you never manually free objects — but does not mandate one specific algorithm. Different implementations are free to use different strategies.
CPython implementation detail: CPython specifically combines two mechanisms: reference counting, which reclaims most objects immediately the instant their reference count drops to zero, and a supplementary cyclic garbage collector, which periodically finds and reclaims groups of objects that reference each other in a cycle (which reference counting alone cannot detect). Both are covered in depth in their own dedicated topics.
Implementation Differences Across Python Runtimes
Because 'garbage collection happens' is the language guarantee and not 'garbage collection happens via reference counting', other Python implementations are free to do it differently — and this has real, observable consequences for code that (incorrectly) depends on CPython's specific timing.
Code that relies on CPython's 'object dies the instant it becomes unreachable' behavior (for example, expecting a file to be closed or a lock to be released the moment an object goes out of scope, without an explicit with block) can silently break on PyPy. Always manage resources explicitly with context managers rather than relying on deallocation timing.
Implementation Memory reclamation strategy
--------------- ----------------------------------------------------
CPython Reference counting (immediate, in most cases) +
periodic cyclic GC for reference cycles
PyPy Generational tracing garbage collector; no per-object
reference counts, so objects are NOT reclaimed the
instant they become unreachable
Jython/IronPython Rely on the host platform's GC (JVM / .NET CLR)The del Statement Does Not Mean 'Delete the Object'
del x removes the name x's binding from its scope — it does not necessarily destroy the underlying object. If other names or containers still reference that object, it remains fully alive.
a = [1, 2, 3]
b = a
del a # only removes the name 'a'; the list itself lives on
print(b) # [1, 2, 3] — still fully accessible via bMemory Leaks as Retained References
Python cannot leak memory in the C sense of losing track of an allocation — but it absolutely can leak memory in the practical sense of objects staying reachable (and therefore alive) forever, or far longer than intended, because something you forgot about still references them.
Common real-world sources of this pattern: caches or registries that never evict entries, event/observer subscriber lists that outlive their subscribers, closures that capture more than they need, and reference cycles involving objects with a __del__ method (see the Cyclic Garbage Collection topic). The Weak References topic covers a targeted fix for the caching/observer case.
class EventBus:
def __init__(self):
self._subscribers = []
def subscribe(self, callback):
self._subscribers.append(callback) # a strong reference is kept!
bus = EventBus()
def handler_factory():
big_data = [0] * 1_000_000
def handler(event):
print(len(big_data))
bus.subscribe(handler) # bus now keeps big_data alive indefinitely
return handler
handler_factory()
# big_data is unreachable from anywhere EXCEPT the closure captured
# inside bus._subscribers — a classic Python "memory leak"Diagnosing Memory Growth
The standard library provides tools for investigating unexpected memory growth without reaching for third-party profilers first.
For deeper investigation, the standard-library tracemalloc module can record where each allocation happened, which is invaluable for tracking down a genuine leak in a long-running process.
import sys
import gc
x = [1, 2, 3]
print(sys.getsizeof(x)) # shallow size in bytes (CPython-specific)
print(sys.getrefcount(x)) # current reference count (CPython-specific;
# includes the temporary ref from this call itself)
print(gc.get_referrers(x)) # what refers to x right now
print(len(gc.get_objects())) # how many objects the cyclic GC is trackingMental Model
Picture memory as a warehouse and reachability as a set of ropes tied from a few fixed anchor points (your global variables, the current call stack) to boxes (objects), and from boxes to other boxes they reference. As long as some unbroken rope-chain connects an anchor to a box, that box must stay. The moment every rope to a box is cut — the last name or container reference to it disappears — the box becomes trash, eligible for cleanup on whatever schedule the implementation uses.
Quick Reference
Concept What it means
--------------- --------------------------------------------------
Allocation memory reserved for a new object
Reference a name/attribute/container slot pointing to an object
Reachability whether some chain of references reaches the object
from a root (globals, stack frames, ...)
Garbage an object that is no longer reachable
Leak (Python sense) reachable-but-unwanted objects kept alive by a
forgotten reference (cache, subscriber list, closure)Key Takeaways
Python manages memory automatically: you never explicitly free objects, only stop referencing them.
Reachability, not scope, is what determines an object's lifetime — a name going out of scope only matters if it was the last reference.
del removes a name binding; it does not necessarily destroy the object, which survives if other references remain.
The language guarantees eventual reclamation of unreachable objects; CPython's specific mechanism (reference counting plus cyclic GC) is an implementation detail, not a universal Python guarantee.
Memory 'leaks' in Python almost always mean an object stayed reachable longer than intended (caches, subscriber lists, closures) — not a lost allocation.
Never rely on deallocation timing for correctness; use explicit context managers for resources like files and locks.
def process(): f = open("data.txt") data = f.read() return data # relying on f going unreachable to close the file "eventually" def process(): with open("data.txt") as f: data = f.read() return data # file is closed deterministically at the end of the with block
Relying on garbage collection to close a resource is implementation-dependent timing, not a guarantee; other Python implementations (e.g. PyPy) may delay it arbitrarily. Always use a context manager for deterministic resource cleanup.
cache = {} def get_data(key): if key not in cache: cache[key] = expensive_computation(key) return cache[key] # cache grows forever — every entry is a permanent strong reference from functools import lru_cache @lru_cache(maxsize=256) def get_data(key): return expensive_computation(key) # bounded cache — old entries are evicted, not kept alive forever
An unbounded dict used as a cache keeps every value reachable (and therefore alive) forever, which is the most common practical 'memory leak' in Python; use a bounded cache like functools.lru_cache or evict entries explicitly.
x = [1, 2, 3] del x print(x) # NameError: name 'x' is not defined — the code assumes the object is gone too x = [1, 2, 3] y = x del x print(y) # [1, 2, 3] — del only removed the name 'x', not the object del removes a name binding from the current scope; it does not guarantee the underlying object is destroyed unless that was the object's last reference.
What is the difference between an object's scope ending and its memory being reclaimed?
Scope ending means a name binding stops existing in that block/function; the underlying object is only reclaimed once it has no reachable references left at all — if another name, container, or closure still references it, it stays alive regardless of the original name's scope ending.
Does del x destroy the object that x referred to? Explain precisely what del does.
Not necessarily. del x removes the binding of the name x from its namespace. The object it pointed to is only reclaimed if that was the last reference to it; if other names or containers still reference the object, it remains fully alive and usable.
Why is it inaccurate to say 'Python's garbage collector always immediately deletes an object when its reference count hits zero'?
Immediate deallocation on refcount-zero is a CPython implementation detail, not a language guarantee — other implementations (e.g. PyPy, which uses a tracing collector) do not use reference counting at all, so they don't reclaim objects the instant they become unreachable; the language only guarantees eventual reclamation of unreachable objects.
In what sense can a Python program 'leak' memory even though the runtime manages memory automatically?
By keeping an object reachable longer than intended — e.g. an unbounded cache dict, a subscriber list that never removes old callbacks, or a closure capturing more state than needed — the object can never be reclaimed because something forgotten still references it, even though technically nothing was 'lost' the way a raw malloc leak loses track of memory.
Why should you use a with statement to close a file rather than relying on the file object becoming unreachable?
Because exactly when an unreachable object's cleanup runs is implementation-dependent — CPython usually does it immediately via reference counting, but this is not a language guarantee — a with block deterministically calls __exit__ (which closes the file) at a precise, predictable point in the code regardless of which Python implementation or GC strategy is running.
What two standard-library tools help you investigate unexpected memory growth, and what does each one show?
The gc module lets you inspect currently tracked objects and referrers (e.g. gc.get_referrers, gc.get_objects) to find what is keeping something alive; the tracemalloc module records where each allocation happened, letting you trace a growth back to the specific code that created the retained objects.
Write a function that demonstrates a reachability-based leak: create a global list, and a function add_leaky_closure() that appends a closure capturing a large local list to that global list each time it's called. Then explain why repeated calls grow memory even though nothing looks obviously wrong.
retained_closures = [] def add_leaky_closure(): big_chunk = [0] * 100_000 def closure(): return len(big_chunk) retained_closures.append(closure) # keeps big_chunk reachable forever for _ in range(10): add_leaky_closure() print(len(retained_closures)) # 10 closures, each keeping its own big_chunk alive
Each closure captures big_chunk in its enclosing scope; appending the closure to the module-level retained_closures list gives it a permanent reachable reference chain (global list -> closure -> big_chunk), so none of the ten big_chunk lists can ever be reclaimed even though no code explicitly 'holds onto' them by name.
What does this print, and what happened to the original [1, 2, 3] list's reachability along the way?
It prints [1, 2, 3]. Reassigning a to a new list only rebinds the name a; b still references the original list object, so it remains reachable through b and is not affected by a's reassignment.
Write a function using gc.get_referrers to show what is keeping a specific list object reachable, given a scenario where a dict holds the only reference to it.
import gc registry = {} target = [1, 2, 3] registry["key"] = target referrers = gc.get_referrers(target) for r in referrers: if r is not registry: continue print("target is referenced by registry:", r) del registry["key"] print(gc.get_referrers(target)) # no longer includes the dict gc.get_referrers(target) returns every object the cyclic collector is tracking that directly references target — here, the registry dict, because it holds target under the key 'key'. After removing that entry, the dict no longer appears among target's referrers, showing that reachability tracking reflects live references, not history.
a = [1, 2, 3]
b = a
a = [4, 5, 6]
print(b)