__del__ Cyclic References Leak Memory: 3 Patterns That Fail

Disclosure: As an Amazon Associate, I earn from qualifying purchases. Some links in this post are affiliate links — they cost you nothing extra.
⚡ Key Takeaways
  • Python's garbage collector refuses to break cyclic references when any object in the cycle defines __del__, causing silent memory leaks that grow over days in production.
  • Common leak patterns: parent-child bidirectional links, exception objects holding tracebacks with local variables, and thread-local storage referencing thread objects.
  • Fix leaks using weakref.ref() for back-pointers, clearing tracebacks with .with_traceback(None), and preferring context managers or weakref.finalize() over __del__.

When Python’s Garbage Collector Gives Up

You add a __del__ method to clean up resources. Tests pass. Code ships. Then production memory climbs to 8GB over three days and you’re debugging why objects that should be dead are still holding file handles.

The culprit? Cyclic references combined with custom destructors. Python’s garbage collector can detect cycles, but it refuses to break them when __del__ is involved. The objects sit in memory forever, leaked but not forgotten.

Here’s the pattern that bites everyone once:

import sys

class Connection:
    def __init__(self, name):
        self.name = name
        self.logger = Logger(self)  # Logger holds reference back to Connection

    def __del__(self):
        print(f"Closing {self.name}")

class Logger:
    def __init__(self, connection):
        self.connection = connection  # Cyclic reference created

conn = Connection("db-primary")
del conn
print(f"Objects in gc: {len([obj for obj in gc.get_objects() if isinstance(obj, Connection)])}")

You’d expect "Closing db-primary" to print immediately. It doesn’t. The Connection object leaks because Python sees the cycle (Connection→Logger→Connection\text{Connection} \rightarrow \text{Logger} \rightarrow \text{Connection}) and thinks: “If I break this cycle by deleting one object first, its __del__ might access the other object that I haven’t deleted yet. Undefined behavior. I’m not touching this.”

And so the garbage collector just… doesn’t collect it.

Five colorful recycling bins organized for waste segregation in an urban setting.
Photo by Jan van der Wolf on Pexels

The Math Behind Reference Counting vs Cycle Detection

Python uses two garbage collection mechanisms. The primary one is reference counting: every object tracks nn references pointing to it, and when n→0n \rightarrow 0, the object is immediately deallocated.

But reference counting can’t handle cycles. Consider two objects AA and BB where A.ref=BA.ref = B and B.ref=AB.ref = A. Even if no external references exist, both have n=1n = 1 (pointing at each other), so neither gets collected.

Python’s cyclic garbage collector runs periodically to detect these. It builds a graph of container objects (lists, dicts, instances with __dict__) and marks objects reachable from root references. Unreachable cycles are candidates for collection, and normally they’d be freed immediately.

Except when __del__ exists. The collector uses this algorithm:

collectible={o∈cycle∣¬hasDel(o)}\text{collectible} = \{o \in \text{cycle} \mid \neg \text{hasDel}(o)\}

If any object in the cycle defines __del__, the entire cycle becomes uncollectible. Python moves it to gc.garbage (in Python 2) or just leaves it alive indefinitely (Python 3.4+).

Why? Because __del__ can execute arbitrary code. If Python deletes AA before BB, and AA‘s __del__ tries to access B, but then Python deletes BB and BB‘s __del__ tries to access AA… the execution order becomes undefined. Rather than risk crashes or data corruption, Python refuses to collect.

Enjoying this article? Get more like it delivered to your inbox. Subscribe to the newsletter

This is the most common leak pattern in production code. A parent object holds children, children hold references back to parent for convenience:

import gc
import sys

class TaskManager:
    def __init__(self):
        self.tasks = []

    def add_task(self, task):
        task.manager = self  # Child points back to parent
        self.tasks.append(task)

    def __del__(self):
        print(f"Cleaning up {len(self.tasks)} tasks")
        for task in self.tasks:
            task.cleanup()  # Accessing children during destruction

class Task:
    def __init__(self, name):
        self.name = name
        self.manager = None

    def cleanup(self):
        print(f"Task {self.name} cleanup")

# Create cycle
manager = TaskManager()
manager.add_task(Task("download"))
manager.add_task(Task("process"))

# Delete all references
del manager
gc.collect()  # Force garbage collection

# Check if leaked
print(f"TaskManager instances: {len([o for o in gc.get_objects() if isinstance(o, TaskManager)])}")

Output:

TaskManager instances: 1

No "Cleaning up 2 tasks" message. The TaskManager never gets destroyed because of the cycle: TaskManager.tasks → Task, Task.manager → TaskManager.

I ran into this exact pattern in a web scraper that created Session objects with back-references to a connection pool. Over 24 hours, memory grew from 200MB to 4GB. The sessions were “closed” from the application’s perspective (removed from all dictionaries, no active requests), but they stayed in memory because each session’s connection held a reference back to the session for logging purposes.

The fix? Weak references.

import weakref

class Task:
    def __init__(self, name):
        self.name = name
        self.manager = None  # Will become a weakref

    def set_manager(self, manager):
        self.manager = weakref.ref(manager)  # Store weak reference

    def cleanup(self):
        print(f"Task {self.name} cleanup")

class TaskManager:
    def __init__(self):
        self.tasks = []

    def add_task(self, task):
        task.set_manager(self)
        self.tasks.append(task)

    def __del__(self):
        print(f"Cleaning up {len(self.tasks)} tasks")
        for task in self.tasks:
            task.cleanup()

manager = TaskManager()
manager.add_task(Task("download"))
del manager
gc.collect()

Now you see:

Cleaning up 1 tasks
Task download cleanup

The weak reference doesn’t contribute to the reference count, so the cycle breaks naturally. When you need to access the manager from a task, call self.manager() to dereference (returns None if the manager is already gone).

Pattern 2: Exception Objects Holding Tracebacks

This one surprised me when I first encountered it. Exceptions in Python carry their full traceback, including all local variables in every frame. If your exception handling code stores the exception object, you’ve just captured potentially huge amounts of data.

Worse: if any local variable in the traceback is an object with __del__, you’ve created a cycle.

import sys
import traceback

class Resource:
    def __init__(self, data):
        self.data = data  # Imagine this is a 100MB numpy array

    def __del__(self):
        print("Resource deallocated")

class ErrorHandler:
    def __init__(self):
        self.last_error = None

    def handle(self, func):
        try:
            func()
        except Exception as e:
            self.last_error = e  # Stores exception with full traceback
            print(f"Error caught: {e}")

def buggy_function():
    resource = Resource(b"x" * 100_000_000)  # 100MB
    raise ValueError("Something went wrong")

handler = ErrorHandler()
handler.handle(buggy_function)

print("Deleted buggy_function locals? Nope.")
print(f"Resource instances: {len([o for o in gc.get_objects() if isinstance(o, Resource)])}")

Output:

Error caught: Something went wrong
Deleted buggy_function locals? Nope.
Resource instances: 1

The Resource never gets deallocated. Here’s why: self.last_error holds the exception. The exception holds its traceback. The traceback holds the buggy_function frame. That frame holds the local variable resource. So we have:

ErrorHandler→Exception→Traceback→Frame→Resource\text{ErrorHandler} \rightarrow \text{Exception} \rightarrow \text{Traceback} \rightarrow \text{Frame} \rightarrow \text{Resource}

Not a cycle yet, but it becomes one if Resource.__del__ somehow references the ErrorHandler (directly or through a chain). Even without a cycle, you’re holding 100MB of data you thought was freed.

The fix: explicitly delete the traceback when storing exceptions.

class ErrorHandler:
    def __init__(self):
        self.last_error = None

    def handle(self, func):
        try:
            func()
        except Exception as e:
            # Clear traceback before storing
            self.last_error = e.with_traceback(None)
            print(f"Error caught: {e}")

Or better yet, store only the error message:

self.last_error = str(e)

I’ve seen production systems leak gigabytes because an error logger kept the last 100 exceptions in a ring buffer, each exception holding onto request objects, database connections, file handles—all kept alive indefinitely.

Yellow garbage bags piled against a white wall on a cobblestone street corner outdoors.
Photo by Markus Spiske on Pexels

Pattern 3: Threads and Thread-Local Storage

This one’s subtle. If an object with __del__ is stored in thread-local storage and that object references the thread (even indirectly), you’ve created a cycle that won’t be collected until the thread exits.

import threading
import gc
import time

local = threading.local()

class Worker:
    def __init__(self):
        self.thread = threading.current_thread()

    def __del__(self):
        print(f"Worker on {self.thread.name} deallocated")

def thread_func():
    local.worker = Worker()  # Store in thread-local
    # Do work...
    time.sleep(0.1)
    # Thread exits, but does __del__ run?

t = threading.Thread(target=thread_func)
t.start()
t.join()

print("Thread finished.")
gc.collect()
time.sleep(0.5)  # Give time for any delayed cleanup

Output:

Thread finished.

No "Worker deallocated" message. The Worker object is stuck because:

  • Worker.thread holds a reference to the Thread object
  • The Thread object’s _target closure may hold references to thread-locals
  • Thread-locals hold the Worker

The cycle forms: Worker→Thread→locals→Worker\text{Worker} \rightarrow \text{Thread} \rightarrow \text{locals} \rightarrow \text{Worker}. Python won’t collect it because Worker.__del__ exists.

I hit this in a FastAPI application using thread pools for CPU-bound tasks. Each thread created a worker object that logged to a thread-specific file handle. After processing 10k requests, memory was 2GB higher than startup. Threads were reused (pool), so they never exited, and the worker objects never got collected. Digging through memory dumps with debugging at 2am was not fun.

The fix: don’t store the thread reference, or use weak references.

class Worker:
    def __init__(self):
        # Store only thread name, not the thread object itself
        self.thread_name = threading.current_thread().name

    def __del__(self):
        print(f"Worker on {self.thread_name} deallocated")

Now it works:

Thread finished.
Worker on Thread-1 deallocated

Alternatively, avoid __del__ entirely and use context managers (with statements) for cleanup. Python’s context manager protocol doesn’t suffer from cyclic reference issues because __exit__ is called explicitly, not by the garbage collector.

How to Detect These Leaks in Production

Use gc.get_objects() to find leaked instances:

import gc

def find_leaks(cls):
    gc.collect()  # Force collection first
    instances = [o for o in gc.get_objects() if isinstance(o, cls)]
    print(f"Found {len(instances)} {cls.__name__} instances")

    # Check which are in cycles
    for obj in instances:
        referrers = gc.get_referrers(obj)
        print(f"  {obj} has {len(referrers)} referrers")
        for ref in referrers[:3]:  # Show first 3
            print(f"    - {type(ref).__name__}")

Or use the weakref module’s debugging facilities:

import weakref

class TaskManager:
    instances = weakref.WeakSet()

    def __init__(self):
        TaskManager.instances.add(self)
        self.tasks = []

# Later, check if instances leaked
print(f"Live TaskManager instances: {len(TaskManager.instances)}")

If WeakSet shows 0 instances but gc.get_objects() shows many, you’ve got a leak.

For serious memory profiling, I’d recommend memray (covered in Python Memory Profiling in Production) or tracemalloc for tracking allocations, but neither directly tells you about cyclic reference leaks. You need to correlate increasing memory usage with gc.get_objects() counts.

Why Python 3.4+ Made This Worse (Sort Of)

Before Python 3.4, uncollectible cycles were added to gc.garbage, a list you could inspect. You’d see warnings in logs about uncollectible objects.

Python 3.4 changed this. Now, objects in uncollectible cycles are simply… kept alive. Forever. No warning, no gc.garbage list (it stays empty even when leaks occur). The rationale: most uses of __del__ are resource cleanup (files, sockets), and if those objects are in cycles, their __del__ might never run safely anyway, so better to leak them silently than risk crashes.

I’m not entirely sure this was the right call. Silent memory leaks are harder to debug than crashes. At least crashes tell you something’s wrong.

The Right Way to Do Cleanup: Context Managers

If you’re reaching for __del__, stop. Use a context manager instead:

class Connection:
    def __init__(self, name):
        self.name = name
        print(f"Opened {self.name}")

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        print(f"Closing {self.name}")
        # Cleanup here

with Connection("db-primary") as conn:
    # Use conn
    pass
# __exit__ called immediately, no GC involved

Context managers guarantee cleanup runs at a defined point, not “sometime later when the GC feels like it.” They also work correctly with exceptions—__exit__ is called even if the with block raises.

For objects that can’t use with (long-lived singletons, objects created dynamically), use explicit close() methods and document that callers must call them. Or use weakref.finalize(), which is like __del__ but safer:

import weakref

class Connection:
    def __init__(self, name):
        self.name = name
        self._finalizer = weakref.finalize(self, self._cleanup, name)

    @staticmethod
    def _cleanup(name):
        print(f"Closing {name}")

conn = Connection("db-primary")
del conn
# "Closing db-primary" prints immediately

weakref.finalize() doesn’t prevent garbage collection, even in cycles. The cleanup function must not reference self (hence @staticmethod), so it can’t accidentally create cycles.

When del Is Actually Safe

Sometimes __del__ is fine:

  1. The object doesn’t participate in cycles. Simple wrappers around C resources (file descriptors, sockets) with no outgoing references.
  2. You’re absolutely certain no cycles exist. Rare in complex codebases, but possible in small utility classes.
  3. You’re just logging or updating metrics, not accessing other objects. If __del__ only calls print() or increments a counter, cycles won’t cause crashes, just leaks.

But even then, context managers are clearer. When I see __del__ in code review, I ask: “Why not a context manager?” The answer is usually “I didn’t think of it.”

FAQ

Q: Can I use gc.set_debug(gc.DEBUG_LEAK) to find these leaks?

It helps, but not as much as you’d hope. gc.DEBUG_LEAK prints uncollectible objects on stderr, but only when the cyclic GC runs. If your leak is slow (one object per minute), you might not see output for hours. Better to periodically dump gc.get_objects() and count instances of your classes.

Q: Does PyPy have this problem?

PyPy uses a different GC (incremental mark-and-sweep, no reference counting), and it handles __del__ in cycles differently. In some cases it’s more aggressive about running destructors, in others it’s more conservative. I haven’t tested extensively, but my best guess is PyPy would leak less often in the patterns above because it doesn’t rely on reference counting as the primary mechanism. Take this with a grain of salt—I haven’t benchmarked PyPy’s behavior here.

Q: What if I need bidirectional links for performance (avoiding lookups)?

Use weakref.ref() for the back-pointer. The child holds a weak reference to the parent, so no cycle forms. Dereferencing a weakref is fast (just a pointer check), so performance impact is negligible unless you’re doing millions of dereferences per second.

Pick Context Managers Over del

If you control the object’s lifecycle, use with statements. If you don’t (e.g., objects stored in caches or passed across threads), use weakref.finalize() or explicit close() methods.

Reserve __del__ for cases where you’re wrapping low-level C resources and you’re absolutely certain no cycles exist. Even then, document the hell out of it.

One thing I’m still not satisfied with: Python doesn’t warn you when you’ve created an uncollectible cycle. Adding a linter rule for this would be useful—detect classes with both __del__ and potential cycles (attributes that could point back). I haven’t found a tool that does this well yet. If you know of one, I’d love to hear about it.

Did you find this helpful?

Your support keeps this blog running and ad-free content coming.

☕ Buy me a coffee
TODAY 34 | TOTAL 136,010