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
Pro tip: Use 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.

Important: Only container objects (objects that can hold references to other objects) are tracked by the GC. Simple types like integers and strings are not.
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.

Warning: Objects with __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 immediately
Expected Output:
--- Fetching User 1 from Database ---
Cache keys: [1]
Cache keys after deletion: []
Deleting strong reference...
Notification: The tracked object has been garbage collected!
Use case: Observer patterns benefit from weak references to prevent observers from keeping subjects alive indefinitely.

__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 points
Expected 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.

Pro tip: In production, use 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.

Back-references: Use 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 = 100

What 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