Skip to content
Codeloom
Python

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.

·6 min read · By Codeloom
Advanced 12 min read

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:

  1. Reference counting: The primary mechanism. Every object has a reference count. When it drops to zero, the memory is freed immediately.
  2. 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:

  1. Starts with all container objects in a generation.
  2. For each object, temporarily decrements the reference counts of objects it refers to.
  3. Objects whose adjusted reference count is zero are potentially unreachable.
  4. It traces reachable objects from known roots and marks them as alive.
  5. 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

  1. Use generators instead of lists for large sequences you iterate once.
  2. Delete large temporaries explicitly with del when you are done.
  3. Use __slots__ on classes with many instances (covered in a separate article).
  4. Profile with tracemalloc before optimizing — measure, don’t guess.
  5. Use weakref for 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.