Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 3

Python Memory Management and Leak Detection

Recommended resources

Introduction

In our last lesson, we examined Python's concurrency models and noted that the GIL's existence is partly to simplify memory management in a multi-threaded context. This provides a perfect entry point into today's topic, which is crucial for building reliable, long-running backend services.

Our learning outcome is to describe Python's memory management and garbage collection, and identify common memory leak patterns in long-running services. We will start with the CPython internals of how memory is managed, and then shift our focus to the practical, production-oriented skill of diagnosing and preventing memory leaks—a common and challenging problem that senior engineers are expected to solve.


CPython's Memory Management Model

Python automates memory management, saving you from the manual malloc and free calls common in languages like C. This automation is primarily handled by two mechanisms working together: reference counting and a cyclic garbage collector.

1. Reference Counting: The Primary Mechanism

The core strategy CPython uses is reference counting. Every object in memory has a counter that tracks how many references point to it.

  • When a new reference to an object is created (e.g., y = x), its reference count is incremented.
  • When a reference is destroyed (e.g., del y, or a variable goes out of scope), its count is decremented.
  • When the reference count reaches zero, the object's memory is immediately deallocated.

This is a fast and deterministic approach, but it has one major weakness.

2. The Problem: Reference Cycles

Reference counting alone cannot handle objects that refer to each other in a cycle. Consider this example:




# A simple reference cycle
a = []
b = []
a.append(b)  # a now references b
b.append(a)  # b now references a

Here, a has a reference from b, and b has a reference from a. If we now delete the original variables:

del a
del b

The reference counts for the list objects are still 1 because they are referencing each other. Reference counting alone would fail to reclaim this memory, leading to a leak.

3. The Solution: The Generational Garbage Collector

To solve the problem of cycles, Python employs a supplementary garbage collector (GC). The specific job of the GC is to find and break these reference cycles.

To get a solid overview of these concepts, let's watch a talk from a CPython core developer.

Pablo Galindo Salgado - Time to take out the rubbish: garbage collector - PyCon 2019

This PyCon talk, 'Time to take out the rubbish: garbage collector' by Pablo Galindo Salgado, provides an excellent and accessible walkthrough of CPython's memory management, focusing on reference counting and the cycle collector.

Please watch these two segments: Reference Counting (00:39 - 06:30): Focus on how reference counting works and how sys.getrefcount behaves. The Problem with Cycles (06:30 - 09:28): This part clearly illustrates why the garbage collector is necessary and how simple cycles are formed.

CPython's GC is also "generational." This is an optimization based on the weak generational hypothesis: most objects die young. The GC groups objects into three "generations":

  • Generation 0 (Young): All new container objects start here. This generation is collected most frequently.
  • Generation 1 (Middle-aged): Objects that survive a Generation 0 collection are promoted here.
  • Generation 2 (Old): Objects that survive a Generation 1 collection are promoted here. This generation is collected least frequently.

Each generation has a threshold (a number of allocations). When allocations in a generation exceed its threshold, a collection is triggered for that generation.

For a more detailed, code-level explanation of these mechanics, the following article is an excellent resource to study.

CPython Garbage Collection: The Internal Mechanics and Algorithms

The article 'CPython Garbage Collection: The Internal Mechanics and Algorithms' by Abhinav Upadhyay provides a deep dive into the CPython source code. It's a fantastic resource for understanding the implementation details.

Please read the following sections: 'A Short Primer on Reference Counting' and its subsections for a recap. 'Limitations of Reference Counting' for a clear example of a cycle. 'Understanding GC Generations' to solidify the concept of generational collection. 'Object Layout for GC Tracking' to see how CPython adds a 'GC header' to container objects.


The Cycle Detection Algorithm (Interview Deep Dive)

While you don't need to recite the C code, understanding the algorithm's logic is what separates a junior from a senior candidate.

When the GC runs on a generation, it performs the following steps to find and break unreachable cycles:

  1. Find Potential Garbage: The GC traverses all objects in the generation. It makes a copy of each object's real reference count (ob_refcnt) into a temporary field (gc_refs). Then, it traverses all objects again. For each object, it finds all other objects it references and decrements their gc_refs counter. After this, any object with a gc_refs > 0 must have a reference from outside the group of objects being collected. These are marked as "reachable".

  2. Identify True Garbage: The remaining objects (with gc_refs == 0) are tentatively considered "unreachable." However, one of these might be referenced by a "reachable" object. The GC iterates through the "reachable" objects. Any object they reference that is currently in the "unreachable" set is "rescued" and moved back to the "reachable" set. This process repeats until no more objects can be rescued. Whatever is left in the "unreachable" list is truly garbage.

  3. Break the Cycles: For each object in the final "unreachable" list, the GC calls a special internal method (tp_clear). This method is implemented by container types (like lists and dicts) to clear their internal references (e.g., list.clear()). This action breaks the reference cycle.

  4. Deallocation: Once the cycles are broken, the objects' real reference counts (ob_refcnt) naturally drop to zero, and the standard reference counting mechanism deallocates their memory.

A key complexity here involves objects with __del__ methods. If an object within a cycle has a __del__ method, Python cannot determine a safe order to destroy the objects, so it leaves them, placing them in the gc.garbage list. This is a common source of "uncollectable" memory.


From Theory to Practice: Memory Leaks in Python

In a language with garbage collection, a "memory leak" isn't memory that is lost forever. It's memory that is still referenced by your application but is no longer needed. The GC can't clean it up because it's still technically reachable. In long-running services, this leads to steadily increasing memory consumption and eventual OOM (Out Of Memory) kills.

Identifying the source of these unwanted references is a critical skill.

Common Memory Leak Patterns

Let's review the most common patterns you'll encounter in backend services.

How to Handle Memory Leaks in Python

The article 'How to Handle Memory Leaks in Python' from OneUptime provides an excellent, practical guide to common leak patterns with clear 'BAD' vs. 'GOOD' code examples.

Read the entire section 'Common Memory Leak Patterns'. Pay close attention to each of the five patterns, especially the solutions using LRU caches, weak references, and explicit cleanup.

Here is a summary of the key patterns from the article:

  1. Unbounded Caches: Using a dict as a cache without a size limit. Every new key adds an entry that is never removed.
    • Fix: Use a bounded cache like functools.lru_cache or a custom TTL cache.
  2. Event Handler/Callback Leaks: An event emitter holds strong references to its listeners. If a listener is a method of an object (self.handle_event), the emitter keeps the entire object alive.
    • Fix: Use weakref to store references to listeners, or provide an explicit unsubscribe() mechanism.
  3. Closure Leaks: A closure can accidentally capture a large object from its enclosing scope, even if it doesn't use it, keeping that object in memory.
    • Fix: Extract only the necessary data into local variables before defining the closure, or use a class instead.
  4. Circular References with __del__: As mentioned, the GC cannot clean up cycles containing objects with __del__ methods.
    • Fix: Avoid __del__. Use context managers (with statement) for cleanup. If you must use cycles, use weakref for back-references (e.g., a child's reference to its parent).
  5. Thread Local Leaks: In a thread pool, threads are reused. Data stored in threading.local() can accumulate across multiple tasks/requests if not cleared.
    • Fix: Ensure data is cleaned up in a finally block after each task. In modern Python (3.7+), contextvars are often a safer alternative for async code.
Optimized vs. Unoptimized Python Memory Usage
This chart summarizes several memory anti-patterns and their optimized alternatives. Note the contrast between strong and weak references, which is a common theme in fixing memory leaks.

Debugging Memory Leaks: Your Toolkit

When you suspect a memory leak, you need a systematic approach and the right tools.

1. The gc Module

For low-level inspection of the garbage collector itself.

  • gc.set_debug(gc.DEBUG_LEAK): Prints information about uncollectable objects.
  • gc.garbage: A list of objects that the GC found to be uncollectable. Inspecting this can be very revealing.
  • gc.get_objects(): Returns a list of all objects tracked by the GC.
  • gc.get_referrers(obj): Returns a list of objects that refer to obj. Essential for tracking down unwanted references.

2. tracemalloc

The built-in library for tracing memory allocations. This is your first and best tool for finding the source of a leak.

The typical workflow is:

  1. tracemalloc.start()
  2. snapshot1 = tracemalloc.take_snapshot()
  3. Run the code that is suspected of leaking.
  4. snapshot2 = tracemalloc.take_snapshot()
  5. top_stats = snapshot2.compare_to(snapshot1, 'lineno')
  6. Print top_stats to see which lines of code were responsible for the new memory allocations.

3. memray and Flame Graphs

For more complex leaks, especially those involving C extensions, memray is a powerful, production-ready memory profiler from Bloomberg. It can generate flame graphs that visualize where memory is being allocated.

Python Memory Profiling Flame Graph
This is an example of a flame graph generated by a memory profiler. The width of each bar represents the amount of memory allocated by that function call. It allows you to quickly spot the 'hottest' code paths responsible for memory consumption.

To see a senior engineer's workflow for debugging a real-world memory leak, this video is an outstanding resource.

using memray to debug (and fix) a memory leak in krb5! (advanced) anthony explains #567

This video, 'using memray to debug (and fix) a memory leak in krb5!' from anthonywritescode, is an advanced, practical demonstration. It shows the end-to-end process of identifying a leak, isolating it, and using native tools to fix it in a C library.

Watch the whole process. Don't worry about the specifics of the C code. Focus on the methodology: Observing the leak with htop. Using memray to generate a flame graph and identify the leaky function area. Enabling options like follow-fork for child processes and --native to trace C-level allocations. Creating a minimal C program to reproduce the issue in isolation. Using system-specific tools (leaks on macOS) to confirm the source in the C code before fixing it.


Interview Scenario: The Leaky Service

Question: "You're responsible for a long-running microservice deployed in Kubernetes. The pod is being OOMKilled every 24 hours. Memory usage on your dashboards shows a slow, steady climb. How do you approach this problem?"

Structured Answer:

  • "My first step is observation and instrumentation. I'd confirm the memory growth pattern using our existing monitoring (e.g., Prometheus). I'd add metrics to track the number of objects tracked by the GC (len(gc.get_objects())) and the size of gc.garbage to see if we have uncollectable cycles."

  • "Next, I'd try to reproduce the leak in a controlled environment. I would set up a staging environment and use a load generator (like Locust) to simulate production traffic. This is critical for safely using more invasive debugging tools."

  • "In the staging environment, I'd use tracemalloc. I'd configure the service to take periodic memory snapshots. By comparing snapshots taken hours apart, I can identify the specific lines of code responsible for the memory growth. This usually points directly to the leak."

  • "Based on the tracemalloc output, I'd investigate for common leak patterns.

    • "If the growing objects are in a dictionary, I'd suspect an unbounded cache.
    • If the trace points to event handling or a callback system, I'd look for listeners not being cleaned up, suggesting a need for weak references.
    • If a specific custom object type is growing, I'd use objgraph to visualize its back-references (objgraph.show_backrefs()) to see what's preventing it from being collected."
  • "If the leak isn't obvious from Python code, I'd suspect a C extension. I would then use a more powerful profiler like memray with the --native flag. This would help me see if the leak is originating from a library like psycopg2 or numpy, as demonstrated in the video."

  • "Finally, after forming a hypothesis and implementing a fix (e.g., replacing a dict cache with lru_cache), I would re-run the load test in the staging environment to confirm that memory usage is now stable before deploying to production."


Conclusion

Understanding Python's memory model is not just academic; it's a practical necessity for building robust backend systems.

Key Takeaways:

  • Python uses reference counting for immediate object deallocation and a generational garbage collector specifically to handle reference cycles.
  • Memory leaks in Python are caused by unwanted references, not "lost" memory. Your job is to find and eliminate these references.
  • Be able to identify common leak patterns: unbounded caches, callback/handler leaks, closure captures, and un-cleared thread-local storage.
  • Your primary toolkit for debugging leaks includes tracemalloc for finding allocation sources, gc for inspecting collector state, and more advanced tools like objgraph and memray for deep analysis.

Preview of the Next Lesson:

We have now concluded our deep dive into advanced Python internals. We are ready to move up a level of abstraction. In the next module, "Core Backend Architecture," we will begin by tackling one of the most fundamental architectural decisions you'll face: analyzing the trade-offs between monolith and microservices architectures.