2025, Sep 27 03:00
Async __del__ in Python: what really happens, why it's not awaited, and correct async cleanup with __aexit__
Learn why async __del__ in Python is never awaited during garbage collection, and how to implement reliable asynchronous cleanup using async with and __aexit__.
Async __del__ in Python: what really happens and how to do cleanup correctly
You can write an async def for many dunder methods, but that doesn’t mean Python will automatically await them. A common question is whether an async __del__ runs during garbage collection. The short answer: it won’t. The coroutine object produced by async __del__ is never awaited, so the cleanup code inside simply doesn’t execute.
Minimal example that looks fine but doesn’t run
Here is a pared‑down class with an asynchronous __del__:
class ResourceSlot:
    async def __del__(self):
        print("Async __del__ called")
obj = ResourceSlot()
del obj
This is valid syntax, but the body of __del__ won’t run. Nothing awaits the coroutine at object finalization time, so the message is never printed. At program shutdown you may at most see a warning that a coroutine was never awaited.
Why this behaves the way it does
Python has a clear contract for dunder methods: the interpreter invokes them in well‑defined circumstances and uses their return values in specific ways. Separately, async def defines a coroutine function. Calling a coroutine function immediately returns a coroutine object; only when that coroutine is awaited does the function body actually execute.
Those rules work together in some places. For example, you can define an async __getitem__. When you index the object, you get back a coroutine and can await it explicitly:
class LazyStore:
    async def __getitem__(self, key):
        print("getting:", key)
        return f"value:{key}"
async def run_lookup():
    store = LazyStore()
    result = await store["alpha"]
    print(result)
That pattern works because you, the caller, control the await. With __del__, the interpreter ignores return values and there is no await site. The coroutine produced by async __del__ has nowhere to be awaited, so the code inside does not execute.
The right way to do async cleanup
For asynchronous finalization, use the asynchronous context manager protocol and place cleanup in __aexit__. Then use the object inside an async with block. This design makes the await explicit and guarantees that your async cleanup runs at the correct time.
Protocol reference: asynchronous context manager protocol in the Python docs: asynchronous context manager.
class AsyncGuard:
    def __init__(self, name):
        self.name = name
    async def __aenter__(self):
        print(f"enter: {self.name}")
        return self
    async def __aexit__(self, exc_type, exc, tb):
        print(f"cleanup: {self.name}")
async def main_task():
    async with AsyncGuard("session-1") as g:
        print("work happens here")
In this pattern, __aexit__ is awaited by the async with machinery, so your cleanup logic runs reliably. This approach is also preferable to __del__ even for purely synchronous use cases, because it makes resource lifetimes explicit and deterministic.
Why you should care
Relying on async __del__ gives the illusion of cleanup without any of the guarantees. The coroutine won’t be awaited, which means your finalization code won’t execute, and you may only see an unawaited coroutine warning as the program exits. Moving cleanup into __aexit__ avoids silent failures and makes your resource handling straightforward and testable.
Conclusion
If you need async cleanup, don’t put it in __del__. Use an asynchronous context manager and place the finalization logic in __aexit__, running the lifetime inside async with. If you need lazy async operations in other places, patterns like async __getitem__ work because callers can await them explicitly. Keep awaits where they can actually happen, and your cleanup will run when it should.
The article is based on a question from StackOverflow by Cherry Chen and an answer by jsbueno.