Memory Management & Optimization
Garbage collection, weak references, __slots__, and 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.
Reference Counting
CPython uses reference counting as its primary memory management strategy. Each object tracks how many references point to it, and when that count reaches zero the object is deallocated immediately. That makes it deterministic: memory comes back the instant it becomes unreachable.
Its blind spot is the reference cycle. Two objects that point at each other keep each other's count above zero, so dropping every name you hold still leaves both alive. The last part of the example below builds exactly that, and only the cycle detector in the next section can free it.
import gc
import sys
import weakref
# Check reference count
x = []
print(sys.getrefcount(x))
# Multiple references
y = x
z = [x]
print(sys.getrefcount(x))
# References decrease when deleted or go out of scope
del y
print(sys.getrefcount(x))
# Circular references are NOT handled by refcounting alone
class Node:
pass
gc.disable() # so only the explicit collect() below can free the cycle
a, b = Node(), Node()
a.other = b # a -> b
b.other = a # b -> a, the refcounts never reach zero
watch = weakref.ref(a)
del a, b
print("alive after del:", watch() is not None)
gc.collect() # the cycle detector is what actually frees it
print("alive after gc.collect():", watch() is not None)
gc.enable()Expected Output:
2 4 3 alive after del: True alive after gc.collect(): False
sys.getrefcount() for debugging, but remember it adds a temporary reference, so the count is always at least 2.Generational Garbage Collection
Reference counting can't handle circular references. Python's gc module detects and collects these cycles using a generational collector, built 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
class Node:
pass
# Get current GC thresholds
print(gc.get_threshold())
# 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()
# Build a cycle, which is exactly what refcounting cannot free
a, b = Node(), Node()
a.other, b.other = b, a
del a, b
# Manually trigger collection
collected = gc.collect()
print(f"Collected {collected} objects")
# Cycles Python refuses to free end up here, not in the collected count
print(gc.garbage)
# DEBUG_COLLECTABLE reports each cycle member as it is freed
gc.set_debug(gc.DEBUG_COLLECTABLE)
c, d = Node(), Node()
c.other, d.other = d, c
del c, d
gc.collect()
# Always reset the flags, or the noise follows you to interpreter exit
gc.set_debug(0)
gc.enable()Expected Output:
(700, 10, 10) Collected 2 objects [] gc: collectable <Node 0x7ec207bfa0f0> gc: collectable <Node 0x7ec207bfa2d0>
The two gc: collectable lines carry the objects' memory addresses, so yours will read differently. Note also that they arrive after the earlier prints only because gc's debug output goes to stderr: pipe this script into a file and the interleaving changes.
__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 let you point at an object without extending its lifetime. That makes them crucial for caches, registries, observer patterns, and anywhere else you need to avoid 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())}")
# 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 immediatelyExpected Output:
--- Fetching User 1 from Database --- Cache keys: [1] Cache keys after deletion: [] Deleting strong reference... Notification: The tracked object has been garbage collected!
__slots__: When and How to Use
By default, Python objects store attributes in a per-instance __dict__. __slots__ is a class-level declaration that replaces that dictionary with a fixed set of attributes stored in a compact array, which is where the memory savings come from.
import tracemalloc
# Without __slots__: every instance carries its own __dict__
class PointDict:
def __init__(self, x, y):
self.x = x
self.y = y
# With __slots__: attributes live in fixed slots, no __dict__ at all
class PointSlots:
__slots__ = ('x', 'y')
def __init__(self, x, y):
self.x = x
self.y = y
# Memory savings scale with instance count, so measure at scale
N = 1_000_000
for cls in (PointDict, PointSlots):
tracemalloc.start()
points = [cls(i, i) for i in range(N)]
used = tracemalloc.get_traced_memory()[0]
tracemalloc.stop()
print(f"{cls.__name__:10} {used / 1024 / 1024:6.1f} MB ({used / N:.0f} bytes per instance)")
del pointsExpected Output:
PointDict 114.9 MB (120 bytes per instance) PointSlots 84.3 MB (88 bytes per instance)
That is 27% less for a two-attribute class on CPython 3.12. The saving grows with the number of attributes you keep out of __dict__, so measure your own class rather than assuming a fixed figure.
✓ 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 so instances can still be weakly referenced
__slots__ = ('name', 'email', '__weakref__')
def __init__(self, name, email):
self.name = name
self.email = email
class Session:
# No '__weakref__' here, so this one cannot be tracked weakly
__slots__ = ('token',)
def __init__(self, token):
self.token = token
u = User("Alice", "alice@example.com")
r = weakref.ref(u)
print(r() is u, r().email)
try:
weakref.ref(Session("abc123"))
except TypeError as e:
print(f"TypeError: {e}")Expected Output:
True alice@example.com TypeError: cannot create weak reference to 'Session' object
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',)
try:
class C(A, B):
__slots__ = ('c',)
except TypeError as e:
print(f"TypeError: {e}")
# ✅ THE FIX: the "Mix-in" Pattern
# To use inheritance, only ONE base class can have non-empty slots.
class Mixin:
__slots__ = () # empty slots keep Mixin a compatible parent
class D(A, Mixin):
__slots__ = ('d',)
d = D()
d.a, d.d = 1, 2
print(d.a, d.d)
# Still no per-instance __dict__, so the saving survives the inheritance
print(hasattr(d, '__dict__'))Expected Output:
TypeError: multiple bases have instance lay-out conflict 1 2 False
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 the top memory-consuming lines. Report line numbers rather than the
# full traceback so the result does not depend on where you saved the file.
snapshot = tracemalloc.take_snapshot()
for stat in snapshot.statistics('lineno')[:3]:
frame = stat.traceback[0]
print(f"line {frame.lineno}: {stat.size / 1024:.1f} KiB in {stat.count} blocks")
tracemalloc.stop()Expected Output:
Current: 38.57 MB Peak: 38.57 MB line 7: 39493.0 KiB in 999745 blocks line 12: 0.1 KiB in 2 blocks line 10: 0.1 KiB in 2 blocks
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("\n--- 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("\nGenerating 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')Expected Output:
--- Most Common Objects in Memory --- function 2082 wrapper_descriptor 1113 dict 1020 tuple 967 builtin_function_or_method 820 method_descriptor 808 ReferenceType 745 getset_descriptor 424 type 382 member_descriptor 325 --- Objects Created Since Last Growth Check --- function 2082 +2082 wrapper_descriptor 1113 +1113 dict 1020 +1020 Generating reference graph 'refs.png'... Graph written to /tmp/objgraph-75exc3ly.dot (34 nodes) Image generated as refs.png Generating back-reference graph 'backrefs.png'... Graph written to /tmp/objgraph-19nf39ty.dot (7 nodes) Image generated as backrefs.png
Captured on CPython 3.12 with objgraph 3.6.2 and Graphviz 2.42. The type counts reflect whatever your interpreter has imported, so they will not match line for line, and the temporary .dot filename is random on every run.
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. Note thatgraphviz must be installed on the system too: 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
# The classic culprit: a module-level cache nobody ever clears
_session_cache = []
class Session:
pass
leaky_object = Session()
_session_cache.append(leaky_object)
# 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.Expected Output:
Graph written to /tmp/objgraph-ynrpj2kf.dot (5 nodes) Image generated as leak_path.png
Common Memory Pitfalls
import tracemalloc
def load_huge_dataset():
return [f"row-{i}" for i in range(200_000)]
# BAD: the closure reads the global, so the whole dataset has to stay alive
tracemalloc.start()
large_data = load_huge_dataset()
def process_bad():
return large_data[0] # Only needs first element!
print(f"bad: {process_bad()}, {tracemalloc.get_traced_memory()[0] / 1024 / 1024:5.1f} MB still held")
tracemalloc.stop()
# GOOD: Extract what you need
tracemalloc.start()
rows = load_huge_dataset()
first_item = rows[0]
del rows # Free memory
def process_good():
return first_item
print(f"good: {process_good()}, {tracemalloc.get_traced_memory()[0] / 1024 / 1024:5.1f} MB still held")
tracemalloc.stop()Expected Output:
bad: row-0, 11.2 MB still held good: row-0, 0.0 MB still held
import pathlib
import tempfile
import tracemalloc
tmp = pathlib.Path(tempfile.mkdtemp()) / "lines.txt"
tmp.write_text("".join(f"line {i}\n" for i in range(200_000)))
# BAD: Entire list in memory
def process_lines_list(filename):
return [line.strip() for line in open(filename)]
# GOOD: Generator, one line at a time
def process_lines_lazy(filename):
with open(filename) as f:
for line in f:
yield line.strip()
for fn in (process_lines_list, process_lines_lazy):
tracemalloc.start()
total = sum(len(line) for line in fn(tmp))
peak = tracemalloc.get_traced_memory()[1]
tracemalloc.stop()
print(f"{fn.__name__:20} {peak / 1024 / 1024:5.1f} MB peak, {total} characters")Expected Output:
process_lines_list 11.4 MB peak, 2088890 characters process_lines_lazy 0.0 MB peak, 2088890 characters
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 memory, 27% on the two-attribute class measured above
- 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