Memory Management & Optimization
Garbage collection, weak references, __slots__, and memory profiling
Why Memory Management Matters
Performance problems are often memory problems in disguise. Excess allocations, reference cycles, and unnecessary object creation can silently degrade performance and cause production incidents. Understanding Python's memory model allows you to write faster, safer, and more predictable systems, especially long-running services.
Interactive Demonstrations
Reference Counting Mechanism
CPython uses reference counting as its primary memory management strategy. Each object tracks how many references point to it. When the count reaches zero, the object is deallocated immediately.
Deep Dive: Reference Counting
Reference counting provides deterministic memory management, objects are freed the instant they become unreachable. However, it has limitations.
import sys # Check reference count x = [] print(sys.getrefcount(x)) # 2 (x itself + temp in function) # Multiple references y = x z = [x] print(sys.getrefcount(x)) # 4 # References decrease when deleted or go out of scope del y print(sys.getrefcount(x)) # 3 # Circular references are NOT handled by refcounting alone a = [] b = [a] a.append(b) # a and b reference each other del a, b # Refcounts are still > 0, memory leak!
sys.getrefcount() for debugging, but remember it adds a temporary reference, so the count is always at least 2.Generational Garbage Collection
Python's GC uses a generational approach based on the observation that most objects die young. Objects are divided into three generations (0, 1, 2), with newer objects collected more frequently.
import gc
# Get current GC thresholds
print(gc.get_threshold()) # (700, 10, 10)
# Generation 0 collects after 700 allocations
# Generation 1 collects after 10 gen-0 collections
# Generation 2 collects after 10 gen-1 collections
# Disable automatic collection (be careful!)
gc.disable()
# Manually trigger collection
collected = gc.collect()
print(f"Collected {collected} objects")
# Check for uncollectable objects (cycles with __del__)
print(gc.garbage)
# Re-enable
gc.enable()
# Debug memory issues
gc.set_debug(gc.DEBUG_LEAK)__del__ methods in reference cycles cannot be collected safely and end up in gc.garbage. Avoid defining__del__ when possible.Weak References in Practice
Weak references are crucial for implementing caches, observer patterns, and preventing memory leaks in long-running applications.
WeakValueDictionary
Dictionary that doesn't prevent its values from being garbage collected. Perfect for caches.
cache = weakref.WeakValueDictionary()WeakKeyDictionary
Dictionary that doesn't prevent its keys from being garbage collected. Great for object metadata.
metadata = weakref.WeakKeyDictionary()import weakref
class User:
def __init__(self, user_id):
self.user_id = user_id
@staticmethod
def load_from_db(user_id):
print(f"--- Fetching User {user_id} from Database ---")
return User(user_id)
def __repr__(self):
return f"User(id={self.user_id})"
# 1. Implementation of a Memory-Efficient Cache
class UserCache:
def __init__(self):
# Entries are removed when no strong references to the 'User' exist
self._cache = weakref.WeakValueDictionary()
def get_user(self, user_id):
if user_id not in self._cache:
user = User.load_from_db(user_id)
self._cache[user_id] = user
return self._cache[user_id]
# --- Testing the Cache ---
cache = UserCache()
# Create a strong reference
user_1 = cache.get_user(1)
print(f"Cache keys: {list(cache._cache.keys())}") # → [1]
# Remove the strong reference
del user_1
print(f"Cache keys after deletion: {list(cache._cache.keys())}") # → []
# The entry vanished from the cache automatically!
# 2. Callback Demo
class SomeClass:
pass
def on_delete(ref):
print("Notification: The tracked object has been garbage collected!")
obj = SomeClass()
# Create weak reference with a callback
weak_link = weakref.ref(obj, on_delete)
print("Deleting strong reference...")
del obj
# Note: In some environments, you might need gc.collect() to trigger this immediately
__slots__: When and How to Use
__slots__ is a class-level declaration that replaces the per-instance__dict__ with a fixed set of attributes stored in a compact array.
# Without __slots__ (default)
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
# Attributes stored in __dict__ (dynamic, flexible, memory-heavy)
# With __slots__ (optimized)
class Point:
__slots__ = ('x', 'y')
def __init__(self, x, y):
self.x = x
self.y = y
# No __dict__, attributes stored in fixed slots
# Memory savings scale with instance count
points = [Point(i, i) for i in range(1_000_000)]
# Could save ~200MB depending on Python version✓ Use __slots__ when:
- Creating many instances (thousands+)
- Memory is constrained
- Attributes are fixed at design time
- Performance matters (slightly faster access)
⚠ Limitations:
- Cannot add attributes dynamically
- Breaks weak references (unless '__weakref__' in slots)
- Complicates multiple inheritance
- No __dict__ (some introspection breaks)
# Allow weak references with __slots__
import weakref
class User:
# We save memory on 'name' and 'email',
# but explicitly pay for a 1-word pointer to allow weakrefs
__slots__ = ('name', 'email', '__weakref__')
def __init__(self, name, email):
self.name = name
self.email = email
u = User("Alice", "alice@example.com")
r = weakref.ref(u)
print(r()) # Works! Without '__weakref__' in slots, this would raise a TypeError.The Layout Conflict Pitfall
Multiple inheritance with __slots__ is restrictive. Python cannot merge multiple non-empty slots from different parent classes into one flat memory layout.
# ❌ THE CONFLICT
class A: __slots__ = ('a',)
class B: __slots__ = ('b',)
class C(A, B):
__slots__ = ('c',)
# Output: TypeError: multiple bases have instance lay-out conflict
# ✅ THE FIX: The "Mix-in" Pattern
# To use inheritance, only ONE base class can have non-empty slots.
class A:
__slots__ = ('a',)
class B:
__slots__ = () # Empty slots allow B to be a compatible parent
class C(A, B):
__slots__ = ('b', 'c') # This works! C inherits 'a' and adds 'b','c'Why does this happen?
In CPython, slots are implemented as a fixed-size struct in C. When a class inherits from two parents with occupied slots, the memory addresses for attributes would overlap, creating a "layout conflict." To keep inheritance clean, use Abstract Base Classes with empty slots or Composition.
Memory Profiling & Leak Detection
Intuition about memory usage is often wrong. Always profile before optimizing.
tracemalloc
Built-in module for tracking memory allocations and finding leaks
memory_profiler
Line-by-line memory usage analysis
objgraph
Visualize object graphs and find reference cycles
import tracemalloc
# Start tracking allocations
tracemalloc.start()
# Your code here
data = [i for i in range(1_000_000)]
# Get memory usage
current, peak = tracemalloc.get_traced_memory()
print(f"Current: {current / 1024 / 1024:.2f} MB")
print(f"Peak: {peak / 1024 / 1024:.2f} MB")
# Get top memory-consuming lines
snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics('lineno')
for stat in top_stats[:10]:
print(stat)
tracemalloc.stop()import objgraph
import gc
import random
# Mock class to demonstrate tracking
class LeakCandidate:
def __init__(self):
self.data = [random.random() for _ in range(1000)]
# 1. Basic Memory Audit
# Manually trigger GC to ensure we are looking at 'true' survivors
gc.collect()
print("--- Most Common Objects in Memory ---")
objgraph.show_most_common_types(limit=10)
# 2. Detecting Leaks (The 'Growth' check)
# In a real app, you would call this at different points in time
print("
--- Objects Created Since Last Growth Check ---")
objgraph.show_growth(limit=3)
# 3. Reference Inspection
# Create a cycle to see how objgraph visualizes it
a = LeakCandidate()
b = LeakCandidate()
a.internal_cycle = b
b.internal_cycle = a
# Generate the graph (requires Graphviz installed on the system)
print("
Generating reference graph 'refs.png'...")
objgraph.show_refs([a], filename='refs.png')
# 4. Finding the Culprit
# 'backrefs' shows what is PREVENTING an object from being collected
print("Generating back-reference graph 'backrefs.png'...")
objgraph.show_backrefs([a], filename='backrefs.png')tracemalloc sparingly (10-20% overhead). For development, combine it with objgraph to track down memory leaks.Visualizing the Heap
Sometimes code looks clean, but objects stay alive because of hidden back-references.objgraph helps you visualize these relationships. It`s an important remark that `graphviz` is required then: `sudo apt-get install graphviz`.
show_backrefs() to answer the question: "I deleted my variable, so why is this object still in memory?" It will show you every parent holding a pointer to your target.# Find why an object isn't being collected import objgraph # This generates a .dot file and converts to PNG # It shows the 'chain of command' keeping the object alive objgraph.show_backrefs([leaky_object], filename='leak_path.png') # Pro Tip: Use this in production heat-checks to see # if your caches are growing unexpectedly.
Common Memory Pitfalls
# BAD: Closure captures entire large_data
large_data = load_huge_dataset()
def process():
return large_data[0] # Only needs first element!
# GOOD: Extract what you need
large_data = load_huge_dataset()
first_item = large_data[0]
del large_data # Free memory
def process():
return first_item# BAD: Entire list in memory
def process_lines(filename):
return [line.strip() for line in open(filename)]
# GOOD: Generator, one line at a time
def process_lines(filename):
with open(filename) as f:
for line in f:
yield line.strip()Key Takeaways
- Reference counting is immediate but can't handle cycles
- Generational GC handles cycles but adds overhead
- Weak references prevent memory leaks in caches and observers
- __slots__ trades flexibility for 50-75% memory savings
- Always profile, intuition about memory is unreliable
- Use generators for large datasets to avoid loading everything into memory
- Avoid global references and be careful with closures capturing large objects
Memory Management Best Practices
Optimization Tips
- Use __slots__ for classes with millions of instances.
- Prefer generators (yield) over large list comprehensions.
- Use weakref.proxy for caches to avoid cycles.
- Reuse objects using Object Pools for high-frequency allocations.
Common Pitfalls
- Defining __del__ in objects involved in circular refs.
- Using global variables for large data structures (leaks).
- Appending to strings in loops (creates many temp objects).
- Blindly disabling the GC in long-running services.
Ready for the Memory Challenge?
🧠 Challenge: The Memory Leak MysteryExpert Level
You are auditing a high-performance Python application. Analyze the code blocks below and identify the memory behavior.
Scenario 1: The Inherited Slot
class Base: __slots__ = ('x',)
class Derived(Base): __slots__ = ('y',)
d = Derived()
d.z = 100What happens when d.z = 100 is executed?
Scenario 2: The Cache Leak
import weakref
class DataWrapper:
def __init__(self, value):
self.value = value
cache = weakref.WeakValueDictionary()
def get_data():
# Wrap the string in a user-defined class instance
val = DataWrapper("Large Dataset")
cache["key"] = val
return "Done"
get_data()
print(len(cache))What is the printed length of the cache?
Scenario 3: The Graph Audit
You run objgraph.show_backrefs([obj]) and see a circular arrow between obj and a list. You have already called del obj.
Why is the object still in memory?
View Explanation
Answer: A reference cycle exists! The list holds a reference to the object, and the object holds a reference to the list. While Python's Cyclic GC will eventually find this, the object stays alive until the next collection cycle (or forever if there are __del__ method complications in older Python versions).
Ready to master memory? Move on to the next module!
What's Next?
You've optimized memory usage! Now let's dive deep into Python internals to understand how CPython works under the hood.
- CPython Internals - Explore Python's C implementation
- Bytecode - Understand Python's compilation and execution
- AST Manipulation - Modify Python code at the abstract syntax tree level