Python GC Lab: Memory Under the Microscope

Diagnose memory leaks and observe Python garbage collection in action.

Introduction

30 Second Summary

Every Python program creates objects that take up memory. When your code stops using them, something needs to reclaim that space. Otherwise, your program slowly consumes more and more memory until it crashes.

In this project, you will build a hands-on garbage collection laboratory. Through a series of Python scripts, you will make memory management visible and build a diagnostic toolkit for finding and fixing memory leaks.

What You'll Build

Imagine running a script and watching Python destroy an object the instant you remove the last reference to it, then breaking that mechanism with a circular reference, and finally diagnosing a real memory leak with a single snapshot command.

By the end of this project, you'll have:

  • A working demonstration of reference counting where you create objects, watch their reference counts change live, and see them destroyed the moment the last reference disappears.
  • A script that manufactures a circular reference that defeats reference counting, then triggers the cyclic garbage collector to reclaim it.
  • A diagnostic workflow using tracemalloc snapshot diffs to pinpoint exactly where a realistic memory leak is accumulating allocations, and a fix that stabilizes memory.
  • Secret Mission: A self-cleaning cache with weak references that automatically evicts entries when objects are no longer needed.

Are there any prerequisites?

This project assumes you are comfortable writing and running Python scripts. You should be familiar with classes, instances, and basic data structures like lists and dictionaries.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up Your GC Lab

Every experiment needs a clean lab. Before you start observing Python's garbage collector in action, you need a dedicated workspace where all your scripts live together.

In this step, you'll create the project directory, confirm Python is accessible from your terminal, and wire up VS Code with your first empty script file.

In this step, get ready to:
  • Create a gc-lab project directory on your Desktop.
  • Confirm Python 3 is installed and accessible.
  • Create an empty 01_refcounting.py file in VS Code.
Create your project directory
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir gc-lab
cd gc-lab
Verify Python and open VS Code
  • Confirm Python 3 is installed by running this command:
python3 --version

You should see a Python 3.x version number printed, such as Python 3.14.7.

Don't see a version number?

  • On Windows, try running python --version instead. Some Windows installations use python rather than python3.
  • If you see command not found on both commands, Python may not be installed or not on your system PATH. Download Python from python.org and install it.
  • Help me fix my Python installation so python3 --version works in my terminal.

Now open VS Code to your new project folder.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Visual Studio Code and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop and select the gc-lab folder.
  • Click Open (macOS) or Select Folder (Windows).

What should I see?

VS Code opens with gc-lab as the workspace. The Explorer sidebar on the left shows the folder name. The folder is empty for now.

Create your first script file

Your first script will explore reference counting. For now, you just need the empty file to confirm your editor and terminal are connected.

  • In the Explorer sidebar, hover over the project name to reveal the action icons.
  • Click the New File icon (the page with a plus sign).
  • Type 01_refcounting.py and press Enter.

The file opens automatically in the editor. Confirm it also exists from the terminal.

  • Open the VS Code integrated terminal by pressing Ctrl+` (the backtick key, below Escape).
  • Confirm the file exists by running:
ls

You should see 01_refcounting.py listed in the output.

Don't see the file?

  • Make sure you're in the gc-lab directory. Run pwd to check your current location.
  • If you see a different directory, close the terminal panel and reopen it with Ctrl+`. VS Code's integrated terminal opens in the project folder by default.
  • Help me find my 01_refcounting.py file in the gc-lab directory.

Your lab is set up. Next up, you'll fill that empty script with code that makes Python's reference counting visible in real time.

Reference Counting in Action

In Step 1, you set up your GC lab and confirmed Python is ready. Now it is time to see how Python manages memory behind the scenes.

CPython destroys most objects the instant their reference count drops to zero. No garbage collector involved. In this step, you will track reference counts in real time and watch objects disappear the moment their last reference is removed.

In this step, get ready to:
  • Define a Sensor class with a finalizer that announces when the object is destroyed.
  • Observe reference counts changing with sys.getrefcount() as you create and delete references.
  • Confirm that __del__ fires immediately when the last reference disappears.
Build the reference counting script

Python's __del__ method is a finalizer. Python calls it just before an object is destroyed. By adding a print statement inside __del__, you can see exactly when Python decides to reclaim the object.

  • In 01_refcounting.py, add the following code:
import sys


class Sensor:
    # Print a message when the object is created
    def __init__(self, name):
        self.name = name
        print(f"  Sensor '{self.name}' created")

    # Print a message when the object is destroyed (finalizer)
    def __del__(self):
        print(f"  Sensor '{self.name}' destroyed")

What does this code do?

  • import sys brings in the sys module, which has the getrefcount() function you will use shortly.
  • The Sensor class prints a message when created (__init__) and when destroyed (__del__).
  • __del__ is your visibility tool. Whenever Python reclaims a Sensor object, you will see the "destroyed" message.
  • Save 01_refcounting.py.
  • The class definition alone does not produce output. The next chunk creates instances and tracks their reference counts.

The sys.getrefcount() function returns how many references currently point to an object. The count it reports is one higher than you would expect, because calling getrefcount() itself temporarily creates a reference.

  • Below the Sensor class in 01_refcounting.py, add this code:
print("=== Reference Counting in Action ===\n")

# Create a Sensor and check its reference count
print("1. Creating a Sensor object:")
sensor = Sensor("temperature")
print(f"   Refcount: {sys.getrefcount(sensor) - 1}\n")

# Adding a second variable pointing to the same object
print("2. Adding another reference (alias):")
alias = sensor
print(f"   Refcount: {sys.getrefcount(sensor) - 1}\n")

# Removing the alias drops the count back down
print("3. Removing the alias:")
del alias
print(f"   Refcount: {sys.getrefcount(sensor) - 1}\n")

# Removing the last reference triggers __del__ immediately
print("4. Removing the last reference:")
del sensor
print("   (Check above: __del__ fired immediately!)\n")

What does this code do?

  • Section 1 creates a Sensor and prints its reference count. Only sensor points to it, so the count is 1.
  • Section 2 creates an alias (alias = sensor). Two variables now point to the same object, so the count rises to 2.
  • Section 3 deletes the alias with del alias. The count drops back to 1. The object survives because sensor still references it.
  • Section 4 deletes the last reference with del sensor. The count hits zero, and __del__ fires immediately.

Before you run this script, think about it: when will the "destroyed" message appear? Will it show up at the end of the script, or at the exact moment del sensor runs?

  • Save 01_refcounting.py and run the script:
python3 01_refcounting.py

You should see the Sensor 'temperature' destroyed message appear right after the "4. Removing the last reference:" line, exactly where del sensor executes. It does not wait for the end of the script.

Don't see the 'destroyed' message?

  • Check that __del__ is indented inside the Sensor class body. If it sits outside the class, Python will not call it as a finalizer.
  • Make sure you typed del sensor (lowercase). Variable names are case-sensitive, so del Sensor would try to delete the class itself.
  • If you see a SyntaxError, double check that every f-string has matching quotes and curly braces.
  • My 01_refcounting.py script is not showing the destroyed message after del sensor. Can you help me debug it?
Extend the script with list aliasing

Aliasing is not limited to simple variables. Any data structure that holds a reference to an object increases its reference count. A list, a dictionary, or an object attribute all count as references.

  • At the bottom of 01_refcounting.py, add this code:
# Demonstrate that aliases on any object type affect refcount
print("5. Aliasing demo with a list:")
a = [1, 2, 3]
print(f"   New list refcount: {sys.getrefcount(a) - 1}")
b = a
c = a
print(f"   After two aliases: {sys.getrefcount(a) - 1}")
del b, c
print(f"   After deleting aliases: {sys.getrefcount(a) - 1}")

What does this code do?

  • A new list a is created with a reference count of 1.
  • b = a and c = a each add another reference to the same list object, bringing the count to 3.
  • del b, c removes both aliases. The count drops back to 1. The list stays alive because a still references it.
  • Save 01_refcounting.py.

✔️ Awesome, I've got everything!

Great. Double check you have saved 01_refcounting.py.

ⓧ I'd like to double check the full code

Your 01_refcounting.py should contain these three sections in order. Scroll up and confirm each one is present:

  • The import sys statement and the Sensor class with __init__ and __del__ methods.
  • Sections 1 through 4: creating a Sensor, adding an alias, removing the alias, and removing the last reference with del sensor.
  • Section 5: the list aliasing demo with a, b, and c.

Before you run the updated script, predict: will the reference count after deleting b and c be 0 or 1?

  • Run the script again:
python3 01_refcounting.py

In section 5, the reference count starts at 1, rises to 3 after creating two aliases, and drops back to 1 after deleting them. The list is not destroyed because a still holds a reference.

Seeing a NameError?

  • If you see NameError: name 'a' is not defined, check that section 5's code is outside and below the Sensor class, not indented inside it.
  • Make sure you did not accidentally delete the variable a before the print statements that reference it.
  • I'm getting a NameError in the aliasing section of 01_refcounting.py. Can you help?

Reference counting handles the majority of Python's memory cleanup instantly and predictably. But what happens when two objects reference each other? In the next step, you will create a circular reference and discover the one scenario where reference counting breaks down.

When References Form a Circle

In the last step, reference counting proved itself. Objects were destroyed the instant their last reference disappeared.

But what happens when two objects point at each other? Each one holds a reference to the other, so neither reference count ever reaches zero. This is the fundamental limitation that motivated Python's cyclic garbage collector.

In this step, get ready to:
  • Build a script with two classes that form a circular reference.
  • Observe that deleting your variables does not trigger __del__ when a cycle exists.
  • Watch the cyclic garbage collector reclaim the orphaned objects.
Build the circular reference script

You need two classes that reference each other to create a cycle. You also need __del__ finalizers on both so you can see exactly when Python destroys them.

  • Create a new file called 02_cycles.py in your gc-lab folder.
  • Add the imports and both class definitions by pasting this code into the file:
import gc
import sys


class Server:
    def __init__(self, name):
        self.name = name
        self.client = None
        print(f"  Server '{self.name}' created")

    def __del__(self):
        print(f"  Server '{self.name}' destroyed")


class Client:
    def __init__(self, name):
        self.name = name
        self.server = None
        print(f"  Client '{self.name}' created")

    def __del__(self):
        print(f"  Client '{self.name}' destroyed")

What does this code do?

  • Server and Client are companion classes. Each has a slot (self.client and self.server) to hold a reference to the other, initially set to None.
  • Both classes have __del__ finalizers that print a message when the object is destroyed. This is your signal to know exactly when Python reclaims each object's memory.
  • gc and sys are imported because you will use gc.disable() to turn off automatic garbage collection and sys.getrefcount() to inspect reference counts.

Now add the code that disables automatic GC, creates two linked objects, and shows their reference counts.

  • Add this code below the class definitions in 02_cycles.py:
gc.disable()
print("=== Circular References ===\n")
print("(Cyclic GC disabled to isolate reference counting)\n")

print("1. Creating linked objects:")
server = Server("main")
client = Client("browser")
server.client = client
client.server = server

print(f"\n2. Reference counts (subtracting 1 for getrefcount's own ref):")
print(f"   server refcount: {sys.getrefcount(server) - 1}")
print(f"   client refcount: {sys.getrefcount(client) - 1}")

What's happening here?

  • gc.disable() turns off Python's automatic cyclic garbage collector. This isolates pure reference counting behavior so you can see its limitation clearly.
  • server.client = client and client.server = server create the cycle. Each object now holds a reference to the other.
  • The refcount for each object should be 2: one from your variable (server or client) and one from the other object's attribute.

Why disable the garbage collector?

Without disabling automatic GC, the cyclic collector might run between your del statements and the observation, making the demo non-deterministic. Disabling it isolates reference counting behavior so you can see the limitation clearly before the cyclic collector steps in.

  • Save 02_cycles.py.
  • Run the script to confirm the objects are created and linked by running:
python3 02_cycles.py

You should see both "created" messages and a refcount of 2 for each object.

Seeing a ModuleNotFoundError or NameError?

  • Make sure import gc and import sys are at the very top of the file.
  • Check that the class definitions come before the gc.disable() line. Python reads files top-to-bottom, so the classes must be defined before you use them.
  • help me debug my 02_cycles.py script
Delete the references and hit the wall

Both objects have a refcount of 2. Deleting your server and client variables removes one reference from each. That should drop each count to 1, held by the other object.

  • Add this code at the bottom of 02_cycles.py:
print("\n3. Deleting our variables:")
del server
del client
print("   ...no __del__ messages appeared!")
print("   Each object still has refcount 1 (held by the other).")
print("   Reference counting alone cannot reclaim this cycle.")

What does this code do?

  • del server and del client remove your variables, dropping each object's refcount from 2 to 1.
  • The objects are now unreachable from your code. You cannot access them anymore. But each one still holds a reference to the other, so neither refcount hits zero.
  • The print statements after del run immediately. If reference counting had destroyed the objects, you would have seen "destroyed" messages appear between the del statements and these prints.
  • Save 02_cycles.py.

Before you run this, think about what you expect. In the last step, __del__ fired the instant you deleted the last reference. Do you think the same thing will happen here after del server and del client?

  • Run the script by running:
python3 02_cycles.py

No "destroyed" messages appear after section 3. The objects are gone from your perspective, but they are still alive in memory. Reference counting cannot reclaim them because each object keeps the other's count at 1.

Seeing __del__ messages in section 3?

  • Make sure gc.disable() appears before the object creation code. If it is missing, Python's automatic cyclic GC might run between the del statements and clean up the cycle before you see the limitation.
  • Check that both server.client = client and client.server = server are present. Without both directions, there is no cycle and reference counting handles it fine.
  • help me understand why __del__ is firing when it shouldn't
Let the cyclic garbage collector step in

The objects are stuck. Reference counting alone cannot break the cycle. This is exactly the situation Python's cyclic garbage collector was built for.

  • Add this code at the bottom of 02_cycles.py:
print("\n4. Calling gc.collect() to trigger cyclic GC:")
collected = gc.collect()
print(f"   Cyclic GC found and collected {collected} unreachable objects.")
print("   __del__ fired above: the collector broke the cycle.")

gc.enable()

What does this code do?

  • gc.collect() manually triggers the cyclic garbage collector. It walks through all tracked objects, finds groups that are only reachable through each other (cycles), and reclaims them.
  • The return value is the number of unreachable objects it found and collected.
  • gc.enable() re-enables automatic garbage collection so Python goes back to managing cycles on its own.
  • Save 02_cycles.py.

Before you run this, do you think gc.collect() will be able to reclaim these objects even though each one still holds a reference to the other?

  • Run the final version of the script by running:
python3 02_cycles.py

This time, section 4 triggers the "destroyed" messages. The cyclic garbage collector detected that both objects were unreachable from your code, broke the cycle, and reclaimed them.

Two cleanup mechanisms working together

CPython uses two complementary strategies. Reference counting handles the easy cases instantly, destroying objects the moment their last reference disappears. The cyclic garbage collector handles the hard cases, detecting and breaking reference cycles that reference counting cannot resolve on its own.

In practice, automatic GC runs periodically in the background so you rarely need to call gc.collect() yourself. You disabled it here to make the limitation visible.

Not seeing the destroyed messages in section 4?

  • Confirm that gc.collect() is called after the del statements, not before them.
  • Make sure the circular reference was actually created. Both server.client = client and client.server = server must be present.
  • help me figure out why gc.collect() isn't reclaiming my circular reference

✔️ Awesome, I've got everything!

Make sure you have saved 02_cycles.py.

ⓧ I'd like to double check the full code

Scroll up and confirm your 02_cycles.py file contains all four chunks in this order:

  • The imports (gc and sys) and the Server and Client class definitions.
  • gc.disable(), object creation, and reference count display.
  • del server and del client with the observation prints.
  • gc.collect() call and gc.enable() at the end.

You have proven that reference counting has a blind spot: circular references. The cyclic garbage collector fills that gap. In the next step, you will look inside the generational system that decides when and how the cyclic collector runs.

Inside the Generational Garbage Collector

In the previous step, you proved that the cyclic garbage collector exists and can reclaim circular references. But you triggered it manually with gc.collect(). In a real program, nobody calls that by hand.

So when does the collector actually run on its own, and how does it decide? Python uses a generational strategy: new objects start in generation 0, and survivors get promoted to generation 1, then generation 2. In this step, you will inspect these generations, watch allocation counts change, and target specific generations for collection.

In this step, get ready to:
  • Read GC thresholds, allocation counts, and per-generation collection statistics.
  • Create reference cycles with GC disabled and observe generation 0 filling up.
  • Compare Python's generational model to other runtimes like V8 and the JVM.
Read GC thresholds, counts, and statistics

Python's cyclic collector groups every object into one of three generations. Generation 0 holds brand-new objects. When generation 0 fills up past a threshold, the collector scans it. Objects that survive get promoted to generation 1, and eventually to generation 2.

Why generations?

Most objects die young. A temporary variable inside a loop, a short-lived string, a one-off calculation result. Scanning every object every time would waste effort on the long-lived ones that are almost never garbage.

Generational collection focuses energy where it pays off most: young objects in generation 0 are checked frequently, while older survivors in generation 1 and 2 are checked less often.

  • Create a new file called 03_gc_generations.py in your gc-lab folder.
  • Add the import, a helper class, and the code that reads GC thresholds by pasting this:
import gc

class TrackedObject:
    def __init__(self, obj_id):
        self.obj_id = obj_id

    def __repr__(self):
        return f"TrackedObject({self.obj_id})"

print("=== Generational Garbage Collection ===\n")

print("1. Default GC thresholds:")
thresholds = gc.get_threshold()
print(f"   Generation 0 threshold: {thresholds[0]}")
print(f"     (collection runs when allocs - deallocs exceeds this)")
print(f"   Generation 1 threshold: {thresholds[1]}")
print(f"     (gen0 collections before gen1 is also examined)")
print(f"   Generation 2 threshold: {thresholds[2]}")
print(f"     (gen1 collections before gen2 is also examined)")

What does this code do?

  • TrackedObject is a simple class you will use later to create reference cycles. It stores an ID and has a readable __repr__ for debugging.
  • gc.get_threshold() returns a tuple of three numbers. The first number is the allocation threshold for generation 0. The second is how many gen 0 collections happen before gen 1 is also examined. The third works the same way for gen 2.
  • Save 03_gc_generations.py and run it to see your thresholds:
python3 03_gc_generations.py

You should see three threshold values printed. The exact numbers depend on your Python version.

Seeing a syntax error?

  • Check that the f-string curly braces {thresholds[0]} use square brackets inside, not parentheses.
  • Make sure the file starts with import gc on its own line.
  • help me debug a syntax error in my GC thresholds script
  • Now add the code that reads current allocation counts and collection statistics. Paste the following at the bottom of 03_gc_generations.py:
print("\n2. Current allocation counts:")
counts = gc.get_count()
print(f"   Gen 0: {counts[0]}, Gen 1: {counts[1]}, Gen 2: {counts[2]}")

print("\n3. Collection statistics since interpreter start:")
stats = gc.get_stats()
for i, gen_stats in enumerate(stats):
    print(f"   Generation {i}: {gen_stats['collections']} collections, "
          f"{gen_stats['collected']} objects collected")

What does this code do?

  • gc.get_count() returns a tuple of three numbers: how many allocations minus deallocations have happened in each generation since the last collection of that generation. When generation 0's count exceeds its threshold, a collection is triggered automatically.
  • gc.get_stats() returns a list of three dictionaries, one per generation. Each dictionary includes collections (how many times that generation has been collected) and collected (total objects reclaimed).
  • Save 03_gc_generations.py and run it again:
python3 03_gc_generations.py

You should now see the thresholds, current allocation counts for all three generations, and collection statistics showing how many collections have already happened just from starting the interpreter and importing gc.

Seeing a KeyError?

  • Make sure the dictionary keys in the f-strings are wrapped in single quotes: gen_stats['collections']. Using double quotes inside an f-string that is itself wrapped in double quotes causes a syntax error.
  • help me fix a KeyError in my gc.get_stats() output loop
Create cycles and observe generation 0 collection

Now that you can read the GC's internal state, you can watch what happens when new objects pile up in generation 0. You will disable automatic GC, create reference cycles, and then target specific generations for collection.

  • Add this code at the bottom of 03_gc_generations.py to disable GC and create five reference cycles:
gc.disable()
print("\n4. Creating 5 reference cycles with GC disabled:")
for i in range(5):
    a = TrackedObject(f"A{i}")
    b = TrackedObject(f"B{i}")
    a.partner = b
    b.partner = a
del a, b

counts_after = gc.get_count()
print(f"   Gen 0 count now: {counts_after[0]} (was {counts[0]})")

What does this code do?

  • gc.disable() prevents automatic collection so the cycles accumulate visibly in generation 0.
  • The loop creates 5 pairs of TrackedObject instances linked by their partner attribute, forming circular references. del a, b removes the loop variables, but each pair still holds a reference to the other.
  • Comparing counts_after[0] to the earlier counts[0] shows how much generation 0 has grown from the uncollected cycles.
  • Now add the collection and statistics code. Paste this at the bottom of 03_gc_generations.py:
print("\n5. Collecting generation 0 only:")
collected_gen0 = gc.collect(0)
print(f"   Objects collected from gen 0: {collected_gen0}")
print(f"   Gen 0 count after: {gc.get_count()[0]}")

print("\n6. Full collection:")
gc.enable()
collected_full = gc.collect()
print(f"   Objects collected: {collected_full}")

print("\n7. Updated statistics:")
stats = gc.get_stats()
for i, gen_stats in enumerate(stats):
    print(f"   Generation {i}: {gen_stats['collections']} collections, "
          f"{gen_stats['collected']} objects collected")

What does this code do?

  • gc.collect(0) collects only generation 0. This is the targeted collection you would normally let Python trigger automatically when gen 0's count exceeds the threshold.
  • gc.enable() re-enables automatic collection before the full gc.collect() call, which sweeps all three generations.
  • The updated statistics at the end show the new totals, including the collections you just triggered manually.
  • Save 03_gc_generations.py and run it:
python3 03_gc_generations.py

You should see the gen 0 count jump after creating the cycles, then drop after gc.collect(0) reclaims them. The updated statistics at the end reflect the collections you triggered.

Gen 0 count didn't change much?

  • Make sure gc.disable() appears before the cycle-creation loop. If automatic GC is still enabled, it may collect the cycles before you observe them.
  • Confirm that each pair of objects references the other through a.partner = b and b.partner = a. Without the cycle, reference counting would reclaim them immediately.
  • help me understand why my gen 0 allocation count isn't growing
Compare Python's model to other runtimes

Python's three-generation model is not unique. The core insight that young objects die fast and old objects should be checked less often appears across many runtimes. The implementations differ, but the vocabulary transfers.

  • Add this cross-runtime comparison at the bottom of 03_gc_generations.py:
print("\n8. Cross-runtime comparison:")
print("   Python: refcounting + 3-generation cyclic GC")
print("   V8 (Node.js): no refcounting, 2-generation GC")
print("     (Scavenge for New Space, Mark-Sweep for Old Space)")
print("   JVM (Java): no refcounting, generational GC")
print("     (Young Gen with Eden/Survivor, Old Gen)")
print("   Core idea is shared: young objects die fast, old objects")
print("   are checked less often. Implementations differ significantly.")

Why mention other runtimes?

The generational hypothesis (most objects die young) is the foundation of garbage collection in V8 (used by Node.js), the JVM (used by Java and Kotlin), and many other managed runtimes. Python is unusual because it combines reference counting with a generational cyclic GC. Most other runtimes rely on tracing collection alone.

Understanding generations in Python gives you the vocabulary to read GC documentation for any of these runtimes.

  • Save 03_gc_generations.py.

Before you run the final version, predict: what do you expect the updated collection statistics (section 7 of the output) to show compared to the initial statistics (section 3)?

  • Run the complete script:
python3 03_gc_generations.py

You should see all eight sections of output: the three generation thresholds, allocation counts, initial collection statistics, gen 0 count growth after creating cycles, objects collected from gen 0, full collection results, updated statistics with higher collection counts, and the cross-runtime comparison.

Script runs but the comparison section is missing?

  • Scroll down in your file and make sure the cross-runtime print statements are after the updated statistics loop, not inside it. Incorrect indentation would place them inside the for loop.
  • help me figure out why my cross-runtime comparison isn't printing

✔️ Awesome, I've got everything!

Great. Double check you have saved 03_gc_generations.py.

ⓧ I'd like to double check the full code

Scroll up through your file and confirm these sections are present, in order:

  • import gc at the top, followed by the TrackedObject class with __init__ and __repr__.
  • The header print and section 1 (thresholds via gc.get_threshold()).
  • Sections 2-3 (allocation counts via gc.get_count() and collection statistics via gc.get_stats()).
  • Section 4 (gc.disable(), cycle-creation loop, del a, b, and gen 0 count comparison).
  • Sections 5-7 (gc.collect(0), gc.enable(), full gc.collect(), and updated statistics loop).
  • Section 8 (cross-runtime comparison print statements for Python, V8, and JVM).

You now have a clear picture of how Python's generational collector organizes objects and decides when to run. Next up, you will use this knowledge to track down a realistic memory leak that the garbage collector cannot fix on its own.

Track Down a Real Memory Leak

In the previous step, you explored the generational garbage collector and saw how Python manages object lifetimes across three generations. Now you are going to tackle the memory problem that trips up experienced developers the most.

Circular references are a textbook scenario. The most common memory leaks in production code happen when your program accidentally keeps references to objects it no longer needs. The garbage collector is working perfectly in these cases. It just cannot collect objects that your code still legitimately references.

In this step, get ready to:
  • Build an event system that accidentally retains objects the garbage collector cannot reclaim.
  • Diagnose the leak using tracemalloc snapshot diffs to pinpoint where memory is accumulating.
  • Fix the leak by cleaning up listeners and verify that memory stabilizes.
Build the event system classes

The leak you are about to create mirrors a pattern found in real production code. An event bus stores callbacks from short-lived workers. The workers disappear, but their callbacks stay registered on the bus, silently holding every worker alive in memory.

  • Create a new file called 04_memory_leak.py in your gc-lab folder.
  • Add the imports and the EventBus class by copying this code into the file:
import gc
import tracemalloc


class EventBus:
    """A simple event system that keeps references to all listeners."""

    def __init__(self):
        self._listeners = {}

    def subscribe(self, event_name, callback):
        if event_name not in self._listeners:
            self._listeners[event_name] = []
        self._listeners[event_name].append(callback)

    def unsubscribe_all(self, event_name):
        if event_name in self._listeners:
            self._listeners[event_name] = []

    def emit(self, event_name, data=None):
        for callback in self._listeners.get(event_name, []):
            callback(data)

    def listener_count(self, event_name=None):
        if event_name:
            return len(self._listeners.get(event_name, []))
        return sum(len(cbs) for cbs in self._listeners.values())

What does this class do?

  • subscribe() stores a callback function in a list keyed by event name. Every callback that gets added stays in the dictionary until something explicitly removes it.
  • unsubscribe_all() clears the entire listener list for a given event. This is the cleanup mechanism you will use later to fix the leak.
  • listener_count() reports how many callbacks are registered. This is your observation tool for spotting retained references.
  • Add the DataProcessor class below the EventBus class:
class DataProcessor:
    """A short-lived worker that subscribes but never unsubscribes."""

    def __init__(self, processor_id, bus):
        self.processor_id = processor_id
        self.data = list(range(10000))
        bus.subscribe("new_data", self.handle_data)

    def handle_data(self, data):
        pass

Why does DataProcessor have a big data list?

Each DataProcessor stores list(range(10000)) to give it a noticeable memory footprint. Without this payload, the leak would be invisible in the tracemalloc output.

The critical line is bus.subscribe("new_data", self.handle_data). This stores a bound method on the bus. The bound method holds a strong reference back to the DataProcessor instance via its __self__ attribute, keeping the entire object alive.

  • Save 04_memory_leak.py and confirm the syntax loads cleanly by running:
python3 04_memory_leak.py

The script exits silently with no output and no errors. The class definitions loaded successfully.

Seeing a SyntaxError or IndentationError?

  • Check that the DataProcessor class starts at the same indentation level as EventBus (column 0, not nested inside it).
  • Make sure the f-string quotes in the docstrings use triple double-quotes, not single quotes.
  • help me debug a SyntaxError in my 04_memory_leak.py file
Manufacture the leak and diagnose it with tracemalloc

Now you will create 50 DataProcessor instances in a loop. Each one subscribes a callback to the bus. When the loop variable goes out of scope, you would expect those processors to be garbage collected. They will not be.

  • Add the Part 1 code below the DataProcessor class. This creates the leak, runs gc.collect() to prove it does not help, and uses tracemalloc to show where memory is accumulating:
# --- Part 1: Observe the leak ---
print("=== Part 1: Memory Leak Through Accidental Retention ===\n")

gc.collect()
tracemalloc.start()
snapshot_before = tracemalloc.take_snapshot()

bus = EventBus()
print("Creating and 'discarding' 50 DataProcessors...")
for i in range(50):
    processor = DataProcessor(i, bus)

del processor
gc.collect()

print(f"Listeners in bus: {bus.listener_count()}")
print("GC ran, but these objects are REACHABLE via the bus.")
print("This is not a GC bug. The bus legitimately holds each callback,")
print("and each callback holds its DataProcessor via __self__.\n")

snapshot_after = tracemalloc.take_snapshot()

print("--- tracemalloc snapshot diff (Top 3) ---")
top_stats = snapshot_after.compare_to(snapshot_before, 'lineno')
for stat in top_stats[:3]:
    print(f"  {stat}")

tracemalloc.stop()

What does this code do?

  • tracemalloc.start() begins tracing Python memory allocations. take_snapshot() captures the current state of all allocations at that moment.
  • The loop creates 50 processors, each subscribing a callback to the bus. After the loop, del processor removes the loop variable, and gc.collect() forces a garbage collection pass.
  • snapshot_after.compare_to(snapshot_before, 'lineno') computes the difference between two memory snapshots, grouped by filename and line number. This pinpoints exactly which lines of code are accumulating memory.
  • Save 04_memory_leak.py.

Before you run the script, think about what you expect to happen. You created 50 processors, deleted the loop variable, and called gc.collect(). Will the listener count be 0 or 50?

  • Run the script to find out:
python3 04_memory_leak.py

The output shows 50 listeners still in the bus. gc.collect() changed nothing. The tracemalloc diff shows significant memory growth concentrated at the DataProcessor.__init__ line where self.data = list(range(10000)) allocates memory.

Why can't the garbage collector help here?

This is the critical insight of this entire project. The garbage collector handles unreachable objects (like the circular references in Step 3). These 50 processors are not unreachable. The bus legitimately holds each callback, and each callback holds its DataProcessor via the bound method's __self__ attribute.

The GC is working perfectly. Only you can fix accidental retention by removing the references your code no longer needs.

Not seeing any tracemalloc output?

  • Make sure tracemalloc.start() appears before the loop that creates processors. Tracing must be active to capture allocations.
  • Check that snapshot_before is taken before the loop and snapshot_after is taken after. The order matters for the diff.
  • help me debug missing tracemalloc output in my memory leak script
Fix the leak and verify memory stabilizes

You have diagnosed the problem. The bus holds callbacks, and each callback holds a processor alive. The fix is straightforward: clear the listener list when the processors are done.

  • Add the Part 2 fix code below the tracemalloc.stop() line at the end of Part 1. This creates a second run with proper cleanup:
# --- Part 2: Fix the leak ---
print("\n\n=== Part 2: Fixing the Leak ===\n")

gc.collect()
tracemalloc.start()
snapshot_fix_before = tracemalloc.take_snapshot()

bus_fixed = EventBus()
print("Creating 50 DataProcessors with proper cleanup...")
for i in range(50):
    proc = DataProcessor(i, bus_fixed)
    proc.handle_data(None)
bus_fixed.unsubscribe_all("new_data")
del proc
gc.collect()

print(f"Listeners after cleanup: {bus_fixed.listener_count()}")

What changed in the fix?

  • The key difference is one line: bus_fixed.unsubscribe_all("new_data"). This clears the listener list, removing all the callbacks that were holding processors alive.
  • Once the callbacks are removed, nothing references the processors. The garbage collector can now reclaim them normally.
  • Add the tracemalloc comparison and summary below the listener count line to complete Part 2:
snapshot_fix_after = tracemalloc.take_snapshot()

print("\n--- tracemalloc snapshot diff after fix (Top 3) ---")
top_stats_fixed = snapshot_fix_after.compare_to(snapshot_fix_before, 'lineno')
for stat in top_stats_fixed[:3]:
    print(f"  {stat}")

tracemalloc.stop()

print("\n--- Summary ---")
print("The GC was never broken. The leak happened because our code")
print("kept references it no longer needed. tracemalloc showed us")
print("exactly where memory was accumulating. The fix: remove the")
print("references when they are no longer needed.")

What does the comparison show?

snapshot_fix_after.compare_to(snapshot_fix_before, 'lineno') runs the same tracemalloc diff on the fixed version. Because the listeners were cleared before deleting the processors, the memory growth should be dramatically smaller compared to Part 1.

  • Save 04_memory_leak.py.

Before you run the full script, predict what the listener count will show in Part 2. Will it be 50 again, or something different?

  • Run the complete script to see both parts side by side:
python3 04_memory_leak.py

Part 1 shows 50 listeners retained and significant memory growth in the tracemalloc diff. Part 2 shows 0 listeners after cleanup and dramatically reduced memory growth. The summary confirms the core lesson.

Part 2 still showing 50 listeners?

  • Make sure Part 2 creates a fresh bus_fixed = EventBus() instead of reusing the original bus variable. The original bus still holds 50 callbacks from Part 1.
  • Check that bus_fixed.unsubscribe_all("new_data") appears after the loop and before del proc.
  • help me fix my Part 2 listener count in 04_memory_leak.py

✔️ Awesome, I've got everything!

Great. Double check you have saved 04_memory_leak.py.

ⓧ I'd like to double check the full code

Scroll up through your code and confirm you have each of these sections in order, matching what you pasted earlier:

  • Imports (gc and tracemalloc) at the top.
  • EventBus class with subscribe, unsubscribe_all, emit, and listener_count methods.
  • DataProcessor class with processor_id, data, and handle_data.
  • Part 1: leak observation with 50 processors, gc.collect(), listener count, and tracemalloc snapshot diff.
  • Part 2: fix with bus_fixed, unsubscribe_all, tracemalloc comparison, and summary prints.

You have built a complete diagnostic workflow: create the problem, measure it with tracemalloc, fix it, and verify the fix. Next up, you will explore a Python feature that lets data structures clean themselves up automatically.

Secret mission

Build a Self-Cleaning Cache with Weak References

In Step 5, you fixed a memory leak by manually unsubscribing callbacks from the EventBus. But what if a data structure could clean up after itself? In this secret mission, you will use Python's weakref module to build a cache that automatically evicts entries when they are no longer needed anywhere else in your program.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your scripts as a reference or delete them entirely.

Resources you used:

  • gc-lab/ directory containing 01_refcounting.py, 02_cycles.py, 03_gc_generations.py, 04_memory_leak.py, and 05_weakrefs.py.

Keep everything running

No action needed. Choose this if you want to revisit the scripts later or use them to demonstrate GC concepts to someone else.

  • Your gc-lab/ directory stays on your Desktop as a handy reference for reference counting, cyclic GC, generational collection, and memory leak diagnosis.
  • The scripts use only Python standard library modules, so there are no dependencies to maintain or update.

Pause - I'll come back to this later

Close VS Code to free up memory. Your files stay on disk and are ready whenever you want to pick back up.

  • Close VS Code. Your gc-lab/ directory and all five scripts remain on your Desktop.
  • To resume, open VS Code and reopen the gc-lab/ folder from your Desktop.

Delete - I don't want to use this again

Remove all project files and start fresh.

  • Close VS Code (or any editor with gc-lab/ open).
  • Delete the gc-lab/ directory from your Desktop.

macOS

  • Open a terminal and run:
cd ~/Desktop
rm -rf gc-lab

Windows

  • Open a terminal and run:
cd ~\Desktop
Remove-Item -Recurse -Force gc-lab
  • Confirm the folder is gone by running ls (macOS) or dir (Windows) in your Desktop directory. You should no longer see gc-lab in the output.

Nice Work!

Nice Work!

Nice work! You just built a hands-on GC laboratory that makes Python's memory management visible and tangible.

You've learned how to:

  • Observe CPython's reference counting in action, discover its limitation with circular references, and watch the cyclic garbage collector reclaim objects that reference counting could not.
  • Explore the generational garbage collector by inspecting thresholds, allocation counts, and per-generation collection statistics to understand when and why collections are triggered.
  • Diagnose a realistic memory leak caused by accidental retention in an event bus, pinpoint the source using tracemalloc snapshot diffs, and fix it by cleaning up references your code no longer needed.
  • Secret Mission: Build a self-cleaning cache with WeakValueDictionary that automatically evicts entries when objects are no longer strongly referenced.

Ready to quiz yourself?