Python Memory Management and Garbage Collection Internals
Explore how CPython manages memory with reference counting, generational garbage collection, and practical tips to avoid memory leaks.
What you'll learn
- ✓How CPython reference counting works
- ✓The generational garbage collector and why it exists
- ✓Detecting and fixing memory leaks
- ✓Using tools like tracemalloc and objgraph
Prerequisites
- •Intermediate Python experience
- •Basic understanding of how memory works
- •Familiarity with object lifecycle
How CPython Manages Memory
Unlike languages with manual memory management like C, Python handles memory allocation and deallocation automatically. CPython uses two primary mechanisms:
- Reference counting: The primary mechanism. Every object has a reference count. When it drops to zero, the memory is freed immediately.
- Generational garbage collector: A secondary mechanism that detects and collects reference cycles that reference counting cannot handle.
Understanding both is essential for writing memory-efficient Python programs.
Reference Counting in Detail
Every Python object has an internal counter tracking how many references point to it. You can inspect this with sys.getrefcount:
import sys
a = [1, 2, 3]
print(sys.getrefcount(a)) # 2 (a + the getrefcount argument)
b = a # Another reference
print(sys.getrefcount(a)) # 3
del b # Remove one reference
print(sys.getrefcount(a)) # 2
References increase when you assign variables, pass arguments, add items to containers, or create attributes. They decrease when variables go out of scope, are deleted with del, or containers are modified.
When the reference count hits zero, CPython immediately deallocates the object. This is why you can often rely on files being closed when their variable goes out of scope (though you should still use context managers).
The Reference Cycle Problem
Reference counting fails when objects reference each other:
class Node:
def __init__(self, value):
self.value = value
self.next = None
a = Node("A")
b = Node("B")
a.next = b
b.next = a # Circular reference!
del a
del b
# Both objects still exist in memory!
# a references b, b references a
# Neither reference count reaches zero
After deleting a and b, each Node still has a reference count of 1 (from the other node’s .next). Reference counting alone cannot free them.
The Generational Garbage Collector
CPython’s cyclic garbage collector handles reference cycles. It uses a generational approach with three generations:
import gc
# View generation thresholds
print(gc.get_threshold()) # (700, 10, 10) by default
- Generation 0: Newly created objects. Collected most frequently.
- Generation 1: Objects that survived one collection.
- Generation 2: Objects that survived multiple collections. Collected least frequently.
The thresholds mean: when 700 new object allocations minus deallocations accumulate, generation 0 is collected. After every 10 generation-0 collections, generation 1 is collected. After every 10 generation-1 collections, generation 2 is collected.
import gc
# Force a collection
collected = gc.collect()
print(f"Collected {collected} objects")
# Get stats
for i, stats in enumerate(gc.get_stats()):
print(f"Generation {i}: {stats}")
How the Cycle Detector Works
The GC uses a “mark and sweep” variant. Simplified, it:
- Starts with all container objects in a generation.
- For each object, temporarily decrements the reference counts of objects it refers to.
- Objects whose adjusted reference count is zero are potentially unreachable.
- It traces reachable objects from known roots and marks them as alive.
- Remaining objects are garbage and get collected.
Only container objects (lists, dicts, classes, instances) participate in cycle detection. Immutable non-container objects (ints, strings, tuples of atomics) cannot form cycles and are skipped.
Weak References
When you need to reference an object without preventing its collection, use weakref:
import weakref
class ExpensiveResource:
def __init__(self, name):
self.name = name
def __del__(self):
print(f"Cleaning up {self.name}")
obj = ExpensiveResource("database")
weak = weakref.ref(obj)
print(weak()) # <ExpensiveResource object>
print(weak().name) # "database"
del obj # Prints "Cleaning up database"
print(weak()) # None
Weak references are essential for caches where you want objects to be collected when no one else needs them:
import weakref
class ImageCache:
def __init__(self):
self._cache = weakref.WeakValueDictionary()
def get_image(self, path):
image = self._cache.get(path)
if image is None:
image = self._load_image(path)
self._cache[path] = image
return image
def _load_image(self, path):
print(f"Loading {path}")
return {"path": path, "data": b"..."}
When no strong references to an image remain, it is automatically removed from the cache.
Detecting Memory Leaks with tracemalloc
Python 3.4 introduced tracemalloc for tracking memory allocations:
import tracemalloc
tracemalloc.start()
# Your code here
data = [list(range(1000)) for _ in range(1000)]
snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics('lineno')
print("Top 5 memory allocations:")
for stat in top_stats[:5]:
print(stat)
For comparing memory between two points:
import tracemalloc
tracemalloc.start()
snapshot1 = tracemalloc.take_snapshot()
# Code that might leak
leaked_data = []
for i in range(10000):
leaked_data.append({f"key_{i}": list(range(100))})
snapshot2 = tracemalloc.take_snapshot()
top_stats = snapshot2.compare_to(snapshot1, 'lineno')
print("Memory changes:")
for stat in top_stats[:5]:
print(stat)
Common Causes of Memory Leaks
1. Unintended Global References
# BAD: Growing global list
_log = []
def process(item):
_log.append(item) # This list grows forever
# BETTER: Use a bounded structure
from collections import deque
_log = deque(maxlen=1000)
def process(item):
_log.append(item) # Automatically drops old entries
2. Closures Capturing Large Objects
# BAD: Closure keeps large_data alive
def create_processor(large_data):
def process(item):
return item in large_data
return process
# large_data is never freed while process exists
3. del Methods Preventing Collection
Objects with __del__ methods in reference cycles were historically problematic. Python 3.4+ (PEP 442) improved this, but __del__ can still cause issues:
# Prefer context managers or weakref.finalize over __del__
import weakref
class Resource:
def __init__(self, name):
self.name = name
self._finalizer = weakref.finalize(self, self._cleanup, name)
@staticmethod
def _cleanup(name):
print(f"Cleaning up {name}")
Tuning the Garbage Collector
For performance-critical applications, you can tune or even disable the GC:
import gc
# Increase thresholds for fewer collections
gc.set_threshold(1000, 15, 15)
# Disable automatic collection (manual control)
gc.disable()
# ... performance-critical section ...
gc.collect() # Manual collection
gc.enable()
# Freeze objects to exclude them from GC checks
# Useful after loading large static data
gc.freeze()
Instagram famously disabled Python’s GC in production, relying solely on reference counting, because the GC’s memory sharing behavior with forked processes caused excessive memory usage.
Practical Memory Optimization Tips
- Use generators instead of lists for large sequences you iterate once.
- Delete large temporaries explicitly with
delwhen you are done. - Use
__slots__on classes with many instances (covered in a separate article). - Profile with
tracemallocbefore optimizing — measure, don’t guess. - Use
weakreffor caches and observer patterns.
Wrapping Up
CPython’s memory management combines the immediacy of reference counting with the safety net of generational garbage collection. Most of the time it works transparently. But when debugging memory leaks or optimizing memory-intensive applications, understanding these internals is invaluable. Use tracemalloc to measure, weakref to avoid unintended retention, and generators to minimize peak memory usage.
Related articles
- Python Python Concurrency: asyncio vs threading vs multiprocessing
Compare Python's concurrency models side by side. Learn when to use asyncio, threading, or multiprocessing with practical benchmarks and real-world examples.
- Python Python Profiling: Find and Fix Performance Bottlenecks
Learn how to profile Python code with cProfile, line_profiler, memory_profiler, and timeit to identify slow functions, memory leaks, and optimize runtime performance.
- Python Python Weak References and Garbage Collection
Understand Python's garbage collection, reference counting, and how weak references prevent memory leaks in caches, observers, and circular structures.
- Python The Python GIL Explained: What It Is and When It Matters
Understand the Global Interpreter Lock in CPython, why it exists, how it affects concurrency, and strategies to work around it.