Skip to Content
Course content

358: Memory Profiling Techniques

Click on the "Edit" button in the top corner of the screen to edit your slide content.

By the time you get to this point in your Python journey, you've likely encountered that moment where your script suddenly slows to a crawl, or worse, your OS kills the process with an OutOfMemory error. The tricky part about Python is that the garbage collector does a lot of the heavy lifting for you, but it can't save you from architectural mistakes or "leaking" references that keep objects alive longer than they should be.

How do I find the specific line of code that's eating all my RAM?

When I'm dealing with a memory spike, I don't want to guess. I want a line-by-line breakdown. For this, memory_profiler is my go-to. It's not built into the standard library, so you'll need to pip install memory_profiler, but it's worth it.

The most straightforward way to use it is the @profile decorator. You don't even need to import it in your code if you run the script via the module command. Here is a realistic scenario: imagine you're processing a large dataset and you're accidentally duplicating a list inside a loop.

# my_script.py
from memory_profiler import profile

@profile
def load_and_process_logs():
    # Imagine this is a 100MB log file
    logs = [f"log line {i}" for i in range(100000)] 
    
    # This is the mistake: creating a new list of the same data 
    # instead of processing it in place or using a generator.
    processed_logs = []
    for line in logs:
        processed_logs.append(line.upper()) 
        
    return processed_logs

if __name__ == "__main__":
    load_and_process_logs()

If you run this with python -m memory_profiler my_script.py, you'll get a table showing exactly how many MiB each line added to the memory footprint. It makes the "aha!" moment happen much faster.

Why is sys.getsizeof giving me numbers that seem too small?

This is a classic trap. I've seen plenty of developers use sys.getsizeof() to debug a leak, only to find it reports a few hundred bytes for a list that contains millions of objects. The reason is that getsizeof provides the shallow size. It tells you how much space the container (the list itself) takes up, but not the objects inside that container.

If you have a list of complex custom objects, sys.getsizeof only counts the pointers to those objects. To get the deep size, you need something like the Pympler library. Here is the difference in action:

import sys
from pympler import asizeof

my_list = [[i for i in range(100)] for _ in range(100)]

print(f"Shallow size: {sys.getsizeof(my_list)} bytes") 
# This only counts the outer list's pointers.

print(f"Deep size: {asizeof.asizeof(my_list)} bytes") 
# This crawls through every nested list and integer.

If you're profiling a complex data structure—like a cache or a tree—always go with asizeof. Otherwise, you're just looking at the tip of the iceberg.

What's the best way to track a memory leak over time?

Line-by-line profiling is great for spikes, but for slow leaks (where memory creeps up over hours), you need a way to compare snapshots. This is where the built-in tracemalloc module shines. I love tracemalloc because it tells you not just how much memory was allocated, but exactly where that allocation happened in your source code.

The strategy here is to take a "baseline" snapshot, run your suspect code, take a second snapshot, and then calculate the difference (the delta).

import tracemalloc

# Start tracing
tracemalloc.start()

def leak_simulation():
    # A global list that keeps growing—a classic leak
    global leak_storage
    leak_storage = []
    for _ in range(1000):
        leak_storage.append("I am a memory leak" * 100)

# Take the first snapshot
snapshot1 = tracemalloc.take_snapshot()

leak_simulation()

# Take the second snapshot
snapshot2 = tracemalloc.take_snapshot()

# Compare the two
top_stats = snapshot2.compare_to(snapshot1, 'lineno')

print("[ Top 3 Memory Increases ]")
for stat in top_stats[:3]:
    print(stat)

This will output the specific file and line number responsible for the most growth. It's an absolute lifesaver when you're hunting for a global variable or a cached object that refuses to be garbage collected.




📋 Practical Task

Exercise: Debugging a Leaky Image Metadata Cache

You are maintaining a system that caches image metadata to avoid repeated disk reads. However, the system is crashing after a few hours of operation. You suspect the cache is growing indefinitely because it never evicts old entries.

Your Goal: Use tracemalloc to identify the line of code causing the memory growth and then implement a fix using a collections.deque with a maxlen to ensure the cache only keeps the most recent 100 entries.

Starting Code:

import tracemalloc

class ImageCache:
    def __init__(self):
        self.cache = [] # This is the suspected leak source

    def add_metadata(self, image_id, data):
        # Simulating adding metadata to the cache
        self.cache.append({image_id: data})

def run_system():
    my_cache = ImageCache()
    for i in range(10000):
        my_cache.add_metadata(i, "Detailed metadata string" * 10)

# TODO: 
# 1. Implement tracemalloc snapshots around run_system() 
# 2. Print the top memory-consuming line to confirm the leak.
# 3. Modify ImageCache to use a deque with maxlen=100.
# 4. Verify that memory usage stays constant after the fix.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.