Fixing Django Signals That Fire Multiple Times on Model Save
You save a model instance and your signal handler runs twice, three times, or more. Emails get sent in duplicate, audit logs pile up with identical rows, and third-party API calls multiply out of nowhere. The model itself is fine β only one row changed β but your signal is clearly misfiring.
This is one of the most common Django gotchas, and it has a handful of distinct root causes. Once you know which one you're hitting, the fix is usually five lines of code or less.
What you'll learn
- Why Django signals sometimes connect more than once
- How receiver decorators interact with app reloads and imports
- How to use
dispatch_uidto deduplicate signal connections - How to guard against re-entrant saves triggering recursive signal calls
- Patterns for structuring signal code so these bugs don't come back
Prerequisites
This article assumes you're working with Django (any version from 3.2 onward), and that you have a basic understanding of how signals and receivers work. You don't need to know anything about the internals β we'll cover the relevant parts here.
How Django Signal Connections Actually Work
Before fixing the problem, it's worth understanding what Django actually does when you register a signal handler. Calling Signal.connect() β either directly or via the @receiver decorator β appends a weak reference to a list of receivers. If the same function gets connected twice, it appears twice in that list, and it fires twice per signal.
Django does have a deduplication mechanism: it hashes receivers by a combination of the function's id and the sender. But that deduplication only works within a single import cycle. If your module is imported twice, or your app's ready() method runs more than once, you can end up with multiple distinct connections to the same logical handler.
Cause 1: Connecting the Signal in the Wrong Place
The most common mistake is calling connect() β or placing your @receiver decorator β inside a function that runs repeatedly, rather than at module load time.
# Bad: this connects a new receiver every time the view is called
def my_view(request):
post_save.connect(handle_user_save, sender=User)
...
Every request adds another copy of handle_user_save to the signal's receiver list. After ten requests, the handler fires ten times per save. The fix is trivial: move signal connections to module level or, better yet, into your app's ready() method.
# Good: connect once, at app startup
from django.apps import AppConfig
class UsersConfig(AppConfig):
name = 'users'
def ready(self):
import users.signals # noqa: F401 β importing the module registers decorators
Cause 2: The App Module Gets Imported Twice
Django's import machinery can sometimes load the same module under two different import paths β for example, myapp.signals and signals if your INSTALLED_APPS entry is inconsistent with your project layout. When that happens, the @receiver decorator runs twice, and both registrations survive because Python thinks they're distinct objects.
The safest fix is the dispatch_uid parameter, which gives each connection a globally unique string identifier. Django uses this string as the deduplication key instead of the function's memory address.
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import Order
@receiver(post_save, sender=Order, dispatch_uid='orders.signals.handle_order_save')
def handle_order_save(sender, instance, created, **kwargs):
if created:
send_confirmation_email(instance)
With dispatch_uid set, Django replaces any existing connection with the same UID instead of appending a second one. This is the single most effective line you can add to any signal handler β make it a habit.
Cause 3: The Handler Saves the Model Again
A sneakier problem: your signal handler itself calls instance.save(), which fires post_save again, which calls your handler again. This is recursive by nature, and it will either loop until a stack overflow or silently run twice depending on how Django's weak-reference cleanup interacts with the call stack.
# This creates a recursive loop
@receiver(post_save, sender=Profile, dispatch_uid='profiles.signals.update_profile')
def update_profile(sender, instance, **kwargs):
instance.last_updated_by = 'signal'
instance.save() # triggers post_save again!
The cleanest solution is to use update_fields together with a guard flag. Pass only the fields you need to update so you can detect a signal-triggered save and skip it:
@receiver(post_save, sender=Profile, dispatch_uid='profiles.signals.update_profile')
def update_profile(sender, instance, created, **kwargs):
# Skip if this save was already triggered by us
if kwargs.get('update_fields') and 'last_updated_by' in kwargs['update_fields']:
return
Profile.objects.filter(pk=instance.pk).update(last_updated_by='signal')
Notice the swap from instance.save() to Profile.objects.filter(...).update(...). A queryset update() call hits the database directly and does not fire model signals, which breaks the loop entirely. This is often the right tool when you need to update a single field without triggering the full model lifecycle.
Cause 4: Multiple Inheritance or Abstract Model Signals
If you're connecting a signal with sender=None (meaning it fires for every model), or if you have an abstract base model whose concrete subclasses all trigger the same signal, you may see one logical event generating multiple calls.
# This fires for every model save in the entire project
@receiver(post_save, dispatch_uid='audit.signals.log_everything')
def log_everything(sender, instance, **kwargs):
AuditLog.objects.create(model=sender.__name__, pk=instance.pk)
This is technically correct behavior β not a bug β but it can surprise you when you forget that sender=None is a wildcard. Always be explicit about the sender unless you genuinely need cross-model behavior.
For abstract model hierarchies, connect the signal on each concrete model separately, or filter inside the handler:
AUDITED_MODELS = {Order, Invoice, Payment}
@receiver(post_save, dispatch_uid='audit.signals.log_audited_models')
def log_audited_models(sender, instance, **kwargs):
if sender not in AUDITED_MODELS:
return
AuditLog.objects.create(model=sender.__name__, pk=instance.pk)
Cause 5: Running Under a Development Server with Auto-Reload
Django's development server restarts worker threads when it detects file changes. During a restart, some signal connections made at import time can survive from the old thread while the new thread also registers fresh connections. The result is a brief window where handlers fire twice.
This almost never happens in production (which typically uses Gunicorn or uWSGI), so it's easy to miss in testing. The fix is the same: dispatch_uid prevents stale registrations from stacking up because the new connection simply overwrites the old one with the same UID.
Debugging Duplicate Signals
When you first suspect a duplicate signal, a quick diagnostic is to print the receiver list directly:
from django.db.models.signals import post_save
from myapp.models import Order
# In a Django shell or management command:
receivers = post_save.receivers
for lookup_key, receiver_ref in receivers:
print(lookup_key, receiver_ref)
Each entry in post_save.receivers is a tuple of a lookup key and a weak reference to the handler function. If you see the same function listed multiple times (same name, different memory addresses), you've confirmed a duplicate connection.
You can also temporarily add a stack trace inside your handler to see exactly how it's being called:
import traceback
@receiver(post_save, sender=Order, dispatch_uid='orders.signals.handle_order_save')
def handle_order_save(sender, instance, **kwargs):
traceback.print_stack()
# rest of handler
This is noisy but useful for a five-minute debugging session.
Common Pitfalls to Avoid
- Omitting
dispatch_uidon any receiver you care about. It's cheap insurance and has no downside. - Calling
instance.save()inside a signal handler without a guard. Always preferqueryset.update()for field-level changes. - Importing signals in
models.pydirectly. This creates circular import risks. UseAppConfig.ready()to import your signals module. - Using mutable default arguments as guard flags. Some tutorials suggest a
_signal_firedattribute on the instance as a reentrancy guard. This works but is fragile if the instance gets pickled or passed across threads. - Connecting signals in test
setUpmethods without disconnecting intearDown. Signal connections persist across tests unless you explicitly disconnect them, which can cause cross-test contamination.
Structuring Signals for Long-Term Maintainability
Keep all your signal handlers in a dedicated signals.py file per app. Import that module only from AppConfig.ready(). Give every receiver a dispatch_uid string that follows a consistent naming convention, such as 'appname.signals.handler_name'.
# 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
# myapp/signals.py
from django.db.models.signals import post_save, pre_delete
from django.dispatch import receiver
from .models import Order
@receiver(post_save, sender=Order, dispatch_uid='myapp.signals.on_order_saved')
def on_order_saved(sender, instance, created, **kwargs):
if created:
notify_fulfillment_team(instance)
@receiver(pre_delete, sender=Order, dispatch_uid='myapp.signals.on_order_deleted')
def on_order_deleted(sender, instance, **kwargs):
archive_order(instance)
With this layout, signals are registered exactly once, the naming is unambiguous, and new team members know exactly where to look.
Next Steps
You have a clear picture of what causes duplicate signal calls and how to prevent each one. Here are concrete actions to take right now:
- Add
dispatch_uidto every@receiverdecorator in your project today. It's a one-line change per handler and eliminates the most common cause of duplicates. - Audit any signal handler that calls
instance.save()and replace those saves withqueryset.update()where appropriate. - Move all signal imports into your app's
AppConfig.ready()method if they aren't already there. - Add a quick test that saves a model and asserts your signal-driven side effect (an email, a log entry, an API call) happens exactly once.
- Run
post_save.receiversin a Django shell against your staging environment to check for duplicate registrations before they reach production.
π€ Share this article
Sign in to saveRelated Articles
Comments (0)
No comments yet. Be the first!