Python Memory Management
Understand Python's garbage collection, reference counting, weak references, __slots__, and memory profiling techniques.
How Python Manages Memory
CPython (the reference implementation) uses two complementary mechanisms:
- Reference counting — every object tracks how many references point to it. When the count drops to zero, the object is immediately deallocated.
- Cyclic garbage collector — handles reference cycles (A → B → A) that reference counting alone cannot collect.
import sys
x = [1, 2, 3]
sys.getrefcount(x) # 2 (x + the argument to getrefcount)
y = x # ref count becomes 3
del y # ref count back to 2
del x # ref count → 0, object freed immediately
Reference Counting
import ctypes
def ref_count(obj_id):
"""Get reference count by object id (after the variable is deleted)."""
return ctypes.c_long.from_address(obj_id).value
a = "hello world" # unique string, not interned
obj_id = id(a)
b = a
print(sys.getrefcount(a)) # 3
del b
print(sys.getrefcount(a)) # 2
del a
# Object is now freed — obj_id is no longer valid
Circular References and the GC
Reference counting cannot collect cycles:
import gc
class Node:
def __init__(self, value):
self.value = value
self.next = None
# Create a cycle
a = Node(1)
b = Node(2)
a.next = b
b.next = a # cycle: a → b → a
del a
del b
# Both objects still alive — their ref counts are 1 (each holds the other)
# The cyclic GC will eventually collect them
The cyclic GC runs periodically (tunable with gc.set_threshold()). It finds unreachable cycles and breaks them.
import gc
# Force a collection
gc.collect()
# Disable for performance-critical sections
gc.disable()
# ... allocate lots of short-lived objects ...
gc.enable()
# Get unreachable objects
unreachable = gc.collect()
print(f"Collected {unreachable} objects")
weakref — References Without Ownership
A weak reference does not increment an object’s reference count. Useful for caches and observer patterns.
import weakref
class ExpensiveResource:
def __init__(self, name):
self.name = name
print(f"Creating {name}")
def __del__(self):
print(f"Destroying {self.name}")
obj = ExpensiveResource("Database Connection")
weak = weakref.ref(obj)
print(weak()) # <ExpensiveResource: Database Connection>
print(weak() is obj) # True
del obj
print(weak()) # None — object was collected
WeakValueDictionary for Caches
import weakref
class Cache:
def __init__(self):
self._store = weakref.WeakValueDictionary()
def get(self, key):
return self._store.get(key)
def set(self, key, value):
self._store[key] = value
cache = Cache()
data = SomeLargeObject()
cache.set("key", data)
del data # WeakValueDictionary does not keep data alive
cache.get("key") # None — automatically evicted
slots
By default, every Python instance stores its attributes in a __dict__. For classes with many instances, this is wasteful.
class PointWithDict:
def __init__(self, x, y):
self.x = x
self.y = y
class PointWithSlots:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x = x
self.y = y
import sys
p1 = PointWithDict(1.0, 2.0)
p2 = PointWithSlots(1.0, 2.0)
print(sys.getsizeof(p1)) # ~48 bytes + 200+ for __dict__
print(sys.getsizeof(p2)) # ~56 bytes, no __dict__
print(hasattr(p1, "__dict__")) # True
print(hasattr(p2, "__dict__")) # False
When slots Matters
import tracemalloc
tracemalloc.start()
# Create 1 million instances
points_dict = [PointWithDict(i, i) for i in range(1_000_000)]
snapshot1 = tracemalloc.take_snapshot()
del points_dict
points_slots = [PointWithSlots(i, i) for i in range(1_000_000)]
snapshot2 = tracemalloc.take_snapshot()
# __slots__ version uses roughly 40-50% less memory
Tradeoffs of __slots__:
- Cannot add arbitrary attributes at runtime
- Does not work well with multiple inheritance
- Not inherited unless subclasses also define
__slots__
Memory Profiling
tracemalloc (stdlib)
import tracemalloc
tracemalloc.start()
# Code to profile
data = {i: str(i) * 100 for i in range(10_000)}
snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics("lineno")
print("Top 5 memory allocations:")
for stat in top_stats[:5]:
print(stat)
tracemalloc.stop()
memory_profiler (pip install memory-profiler)
from memory_profiler import profile
@profile
def process_data():
data = [i**2 for i in range(100_000)]
result = sum(data)
del data
return result
process_data()
# Line-by-line memory usage printed to console
objgraph (pip install objgraph)
import objgraph
# Show the most common types in memory
objgraph.show_most_common_types(limit=10)
# Find what's holding a reference to an object
obj = SomeClass()
objgraph.show_backrefs(obj, max_depth=3)
Generator-Based Memory Efficiency
import sys
# List — loads everything into memory
data_list = [x**2 for x in range(1_000_000)]
print(sys.getsizeof(data_list)) # ~8 MB
# Generator — computes on demand
data_gen = (x**2 for x in range(1_000_000))
print(sys.getsizeof(data_gen)) # ~128 bytes
# Both sum to the same value
sum(data_list) == sum(data_gen) # True
intern() for String Memory
Python automatically interns short strings that look like identifiers. You can force interning for repeated strings:
import sys
a = "hello"
b = "hello"
a is b # True — CPython interns this automatically
# Force interning for arbitrary strings
s1 = sys.intern("some long repeated string")
s2 = sys.intern("some long repeated string")
s1 is s2 # True — same object, saves memory when repeated millions of times Frequently Asked Questions
Does Python have manual memory management?
No. Python uses automatic memory management through reference counting plus a cyclic garbage collector. You rarely need to think about memory, but understanding the model helps you avoid leaks and optimize usage.
What is a memory leak in Python?
The most common causes are unbounded caches, circular references in objects that define __del__, and global state that accumulates data over time.
When do __slots__ actually help?
__slots__ help when you create millions of instances of a class. Each instance without __slots__ carries a __dict__ (typically 200-300 bytes overhead). With __slots__ that overhead drops to almost nothing.