Django Signals Firing Multiple Times: Causes and How to Fix Them
You added a post_save signal to send a welcome email when a user is created, and now every new user gets two or three emails. Or your audit log has duplicate entries for every save. The signal handler is definitely connected β it's just connected more than once.
This is one of the more frustrating Django bugs because the symptom shows up at runtime, the root cause lives in import mechanics or app startup code, and the Django docs don't spell out how easily it goes wrong.
What You'll Learn
- How Django registers signal receivers and why duplicates happen
- The five most common causes of signals firing multiple times
- How to use
dispatch_uidas a reliable deduplication tool - The correct place to connect signals in a Django project
- Patterns to verify registration and write safer receiver code
Prerequisites
You should be comfortable with Django's Signal, @receiver decorator, and the basic app lifecycle (AppConfig, ready()). Examples use Django 4.x and Python 3.10+, but the behavior described here has been consistent for many major Django versions.
How Django Signal Registration Actually Works
A Django signal maintains an internal list of receivers. Each call to Signal.connect() β whether directly or through the @receiver decorator β appends to that list. When the signal is sent, Django calls every function in the list, in order.
The critical detail: Django does not deduplicate receivers by default. If you connect the same function twice, it runs twice. Django does check for identical (function, sender) pairs and will skip an exact duplicate only if dispatch_uid is not in play β but that check is more fragile than you'd expect, as you'll see below.
You can inspect a signal's current receiver list at any time:
from django.db.models.signals import post_save
# Returns a list of (lookup_key, receiver_ref) tuples
print(post_save.receivers)
If you see the same function referenced more than once, you've confirmed a duplicate registration. Now let's find out why it happened.
Cause 1: Importing the Module Multiple Times
The @receiver decorator runs at import time. Every time Python imports the module that contains your decorated handler, the decorator calls Signal.connect(). Normally Python caches modules in sys.modules, so this runs only once. But certain patterns break that caching.
The classic offender is placing your signals in a file that gets imported from multiple places:
# myapp/signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.contrib.auth.models import User
@receiver(post_save, sender=User)
def send_welcome_email(sender, instance, created, **kwargs):
if created:
# send email
pass
# myapp/views.py -- DON'T do this
from myapp import signals # triggers registration
# myapp/models.py -- also imports it
from myapp import signals # triggers registration again
In practice Python's module cache usually prevents true re-imports, but circular imports can cause a module to be partially executed more than once, effectively running the decorator twice. If you're seeing duplicates and your signals file is imported in several places, check for circular import chains using a tool like importlab or by adding a print statement at the top of the module.
Cause 2: Registering Inside a Function or View
This one is straightforward but surprisingly common, especially in codebases where signals were added incrementally.
# BAD: every request to this view re-registers the handler
def my_view(request):
post_save.connect(my_handler, sender=MyModel)
# ... rest of view logic
Each HTTP request calls connect() again. After ten requests, the handler runs ten times per save. The fix is simple: move signal connections out of any callable and into module-level or AppConfig.ready() scope.
Cause 3: AppConfig.ready() Called More Than Once
The recommended Django pattern is to import your signals inside AppConfig.ready():
# myapp/apps.py
from django.apps import AppConfig
class MyAppConfig(AppConfig):
name = "myapp"
def ready(self):
import myapp.signals # noqa: F401
This is correct β unless your app is listed in INSTALLED_APPS more than once, or unless you have both the plain module string and the dotted AppConfig path:
# settings.py -- BAD: app registered twice
INSTALLED_APPS = [
"myapp",
"myapp.apps.MyAppConfig", # duplicate!
]
Django will call ready() for each entry, importing your signals twice. Keep exactly one entry per app. The AppConfig dotted path is preferred because it's explicit.
A subtler variant: if you define a default_app_config in myapp/__init__.py and list the AppConfig path in INSTALLED_APPS, you may get double initialization depending on the Django version. Since Django 3.2, default_app_config is deprecated for exactly this reason β remove it.
Cause 4: Connecting the Same Receiver Manually Multiple Times
When you use Signal.connect() directly instead of the decorator, it's easy to accidentally call it in multiple places:
# myapp/apps.py
def ready(self):
from myapp.signals import handle_order_created
from myapp.models import Order
from django.db.models.signals import post_save
post_save.connect(handle_order_created, sender=Order)
# myapp/__init__.py -- also runs at startup in some setups
from myapp.signals import handle_order_created
from myapp.models import Order
from django.db.models.signals import post_save
post_save.connect(handle_order_created, sender=Order) # registered twice!
Django's built-in duplicate check compares the receiver's id and the sender. But if the function is a bound method or a lambda, the id can differ across two calls even for logically identical functions, so Django doesn't catch it. Stick to one registration point per receiver.
Cause 5: Multiple Apps Connecting to the Same Signal
If you have multiple Django apps and each has its own AppConfig that connects a handler for the same signal and sender, you'll get one call per app. This is usually intentional β but sometimes a shared utility module gets included in several ready() methods:
# app_a/apps.py
def ready(self):
from shared.handlers import audit_log
post_save.connect(audit_log, sender=MyModel)
# app_b/apps.py
def ready(self):
from shared.handlers import audit_log
post_save.connect(audit_log, sender=MyModel) # same function, registered again
The fix here is to designate one app as the owner of shared signal connections, or to use dispatch_uid (covered next) to enforce uniqueness globally.
Using dispatch_uid to Deduplicate Receivers
dispatch_uid is Django's built-in solution for guaranteed-unique registration. When you supply this string, Django uses it as the key instead of the function's id. Connecting the same dispatch_uid a second time replaces the first registration rather than adding to it.
# With the decorator
@receiver(post_save, sender=User, dispatch_uid="myapp.send_welcome_email")
def send_welcome_email(sender, instance, created, **kwargs):
if created:
pass
# Or with connect()
post_save.connect(
send_welcome_email,
sender=User,
dispatch_uid="myapp.send_welcome_email",
)
Make the UID string globally unique across your entire project. A good convention is <app_label>.<signal_name>.<handler_name>. This makes it self-documenting and eliminates collision risk.
dispatch_uid is not a substitute for fixing the underlying registration problem β it's a safety net. You still want to understand why duplicates occur; relying solely on dispatch_uid can mask deeper structural issues that cause other bugs later.
Structuring Your signals.py Correctly
The canonical, collision-resistant pattern for a Django app looks like this:
1. Define handlers in myapp/signals.py
# myapp/signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.contrib.auth.models import User
@receiver(post_save, sender=User, dispatch_uid="myapp.signals.send_welcome_email")
def send_welcome_email(sender, instance, created, **kwargs):
if not created:
return
# send email logic here
2. Import signals exactly once, inside ready()
# myapp/apps.py
from django.apps import AppConfig
class MyAppConfig(AppConfig):
name = "myapp"
default_auto_field = "django.db.models.BigAutoField"
def ready(self):
import myapp.signals # noqa: F401 β side-effect import
3. Register only the AppConfig in INSTALLED_APPS
# settings.py
INSTALLED_APPS = [
# ...
"myapp.apps.MyAppConfig",
]
4. Do not import signals anywhere else
Remove any from myapp import signals or import myapp.signals lines from models.py, views.py, or __init__.py. The ready() method is the single source of truth.
This pattern is consistent with how Django initializes middleware and other startup components β everything that needs to run once at startup belongs in the app lifecycle, not in request-handling code.
Common Pitfalls to Watch For
Testing environments re-import modules
Test runners like pytest-django sometimes reload apps between test sessions or use --reuse-db in ways that can cause ready() to be called again. Using dispatch_uid protects you here. Alternatively, disconnect signals explicitly in test teardown:
# In a pytest fixture or TestCase.tearDown
from django.db.models.signals import post_save
from myapp.signals import send_welcome_email
post_save.disconnect(send_welcome_email, sender=User)
This is especially relevant if you've noticed your signal tests failing intermittently depending on test order. The same kind of state-bleed problem is discussed in the context of React useEffect firing twice in development β both stem from framework lifecycle hooks running more than once in non-production modes.
Using lambda or inner functions as receivers
Each lambda expression creates a new object with a unique id at runtime. If you connect a lambda inside any function that's called more than once, you'll accumulate handlers:
# BAD: new lambda object every call, dedup check always fails
post_save.connect(lambda sender, **kw: do_something(), sender=MyModel)
Always use a named, module-level function as your receiver. If you need a lambda for brevity, at minimum supply a dispatch_uid.
Forgetting weak references
By default, Django stores receivers as weak references. If your handler is a bound method on an object that gets garbage-collected, the signal silently stops firing. This is the opposite problem from what we've been solving, but it surfaces in the same area of code. Pass weak=False only when you're certain the receiver object will outlive the signal's use, such as a module-level function (which already can't be garbage-collected).
Not guarding against the created flag
A post_save signal fires on both create and update. If your handler doesn't check created, it may appear to fire
Frequently Asked Questions
Why does my Django post_save signal fire twice for every save?
The most common reason is that your receiver function is registered more than once β usually because the module containing the @receiver decorator is imported in multiple places, or your app appears twice in INSTALLED_APPS. Use dispatch_uid with a unique string to enforce that only one registration is kept.
What does dispatch_uid do in Django signals?
dispatch_uid is a unique string identifier you pass to Signal.connect() or the @receiver decorator. When Django sees it, it uses that string as the registration key instead of the function's id, so connecting the same dispatch_uid a second time replaces the first entry rather than adding a duplicate.
Is it safe to import signals inside AppConfig.ready()?
Yes, this is the officially recommended pattern. The ready() method is called exactly once during application startup, making it the correct place to perform side-effect imports like signal registration. Just ensure your app is not listed more than once in INSTALLED_APPS.
How can I check how many times a Django signal receiver is registered?
Inspect the signal's receivers attribute directly in a Django shell: from django.db.models.signals import post_save; print(post_save.receivers). Each entry in the list represents one registered handler, so if you see your function listed multiple times, it has been connected more than once.
Can Django signals fire multiple times during unit tests?
Yes, test suites can trigger duplicate registrations if the app is initialized multiple times or if a previous test connected a handler without disconnecting it afterward. Use dispatch_uid for safety, and explicitly disconnect handlers in test teardown or use mock.patch to isolate signal behavior during testing.
π€ Share this article
Sign in to saveRelated Articles
Comments (0)
No comments yet. Be the first!