Skip to content
4 min read

Blast Radius #4: Django's Signal Dispatcher Leaked 76MB. The Fix Was -3 Lines.

A first-time Django contributor used Claude to find a memory leak hiding in weakref.finalize(). One file changed. 2,060 files in the blast radius.

By CodeLayers Team

Blast Radius #4: Django's Signal Dispatcher Leaked 76MB. The Fix Was -3 Lines.

Someone running Django with Celery workers noticed their memory climbing. Not crashing, not spiking. Just climbing, steadily, across every worker process, until they hit the limits and got recycled.

They filed ticket #36939: "Connecting the same signal multiple times with weak=True allocates new memory that is never deallocated." A tracemalloc dump pointed straight at django/dispatch/dispatcher.py.

Juho Hautala picked up the ticket. His open source footprint is mostly JavaScript. He maintains node-hcaptcha, a zero-dependency hCaptcha library with 141 stars. This was his first Django PR.

He used Claude to narrow down the leak source. Claude identified weakref.finalize() as the culprit and suggested tracking finalize objects and calling detach() on disconnect. Juho found something simpler.


The PR

django/django#20754. "Fixed #36939 -- Avoided weakref.finalize in Signal.connect()."

Django's signal system lets you hook into framework events. post_save fires after a model is saved to the database. request_started fires when a new HTTP request arrives. user_logged_in fires on authentication. The dispatcher connects handlers to these signals, and by default it holds weak references so handlers can be garbage collected when nothing else references them.

Thirteen months earlier, Simon Charette fixed a different signal bug (#35801). Charette has been a Django core contributor for over a decade. Python's id() function can return the same value for objects with non-overlapping lifetimes, which meant dead senders could collide with new ones. His fix introduced weakref.finalize() to detect when receivers die:

# Before the fix (Jan 2025): three objects per connect
receiver_object = receiver
# Check for bound methods
if hasattr(receiver, "__self__") and hasattr(receiver, "__func__"):
    ref = weakref.WeakMethod
    receiver_object = receiver.__self__
receiver = ref(receiver)
weakref.finalize(receiver_object, self._flag_dead_receivers)

weakref.finalize() creates a callback object that fires when its target gets garbage collected. The problem: if you connect and disconnect the same handler repeatedly (common in test suites, Celery worker reinitialization, and dynamic signal wiring with dispatch_uid), each cycle creates a new finalize object. The handler stays alive, so the finalize never fires. The objects accumulate.

Over 100,000 connect/disconnect cycles, memory climbed from 25 MB to 101 MB. The top allocator in tracemalloc was dispatcher.py:163 with 200,001 objects consuming 9,375 KiB.

Juho's fix: weakref.ref() already accepts a callback parameter. When the referent gets garbage collected, the callback fires automatically. No separate finalize object needed.

# After: one line, zero extra objects
receiver = ref(receiver, self._flag_dead_receivers)

Four lines deleted, one line changed. Net -3 lines. Memory after 100,000 cycles: 25.34 MB. The dispatcher doesn't even appear in the top 10 allocators anymore.


What the dependency graph shows

GitHub says 1 file changed. The dependency graph says 2,060.

dispatcher.py is the base of Django's entire event system. It's imported by django/db/models/signals.py, which defines pre_save, post_save, pre_delete, post_delete, and m2m_changed. Those signals are used by every Django model. The models are used by every view, every form, every admin page, every management command.

The cascade continues through django/core/signals.py (every HTTP request), django/contrib/auth/signals.py (every login), django/db/backends/signals.py (every database connection), and django/test/signals.py (every test that changes settings).

One file sits at the root. 2,060 files depend on it, directly or transitively. Every Django application that uses signals runs through this code path. That's every Django application.

Explore django/django#20754 in 3D →

Blast radius of dispatcher.py across Django's depth rings

Select dispatcher.py and expand the blast radius. 2,060 files light up: the ORM layer first, then auth, then admin, then the entire test suite.


The bigger story

Adam Johnson reviewed the PR. He's a Django Security Team member, PSF Fellow, author of four books on Django, and co-organizer of Django London. In 2019, he wrote "Working Around Memory Leaks in Your Django Application". It became the canonical reference on the topic. His advice at the time: set max-requests in uWSGI or max-tasks-per-child in Celery to periodically restart workers, because the root causes were too hard to pin down.

Seven years later, he reviewed the fix for one of those root causes. His comment: "LGTM! And today I learned about weakref.finalize(), which seems like something to generally avoid…"

This is the fifth signal-related memory or lifecycle ticket in Django's history. Ticket #16679 in 2011 introduced receiver caching that caused memory issues. Ticket #20943 in 2013 fixed the cache holding strong references to senders. Ticket #35801 in 2025 fixed sender ID collisions, and inadvertently introduced the finalize leak. Now ticket #36939 closes that loop.

The AI disclosure in the PR is worth reading in full: "Claude was used to pinpoint the issue that lead to the leak which was weakref.finalize(). It then suggested to keep track of the return values from weakref.finalize() and to detach() them when disconnect()ing signals. Instead of accepting those changes a much simpler fix was discovered to discard weakref.finalize() usages all together."

The AI found the needle. The human saw that the haystack was unnecessary.


See it yourself

File detail panel showing dispatcher.py dependencies

Zoom into the cluster around django/db/models/signals.py and trace how post_save and pre_delete fan outward into every model in the framework. The file detail panel shows every downstream dependent.


Blast Radius is a weekly series. Want this on your own PRs? The GitHub Action posts a 3D visualization on every push. Got a PR you want us to look at? Tell us on Bluesky.

Want to see your code in 3D?

Download on the App Store

Get notified about updates and new features: