Skip to content
Codeloom
Django

Django Signals Deep Dive: post_save, pre_delete, and Custom Signals

Master Django signals — connect to post_save and pre_delete, write custom signals, avoid common pitfalls, and know when to use them.

·6 min read · By Codeloom
Intermediate 12 min read

What you'll learn

  • How Django signals work under the hood with the dispatcher
  • How to use post_save and pre_delete for side effects
  • How to create and send custom signals
  • Common pitfalls like duplicate firing and circular imports
  • When to use signals vs overriding save() or using services

Prerequisites

  • Basic Django models — see Django Models and the ORM
  • Familiarity with Django apps and project structure

Django signals let decoupled parts of your application react to events. When a model saves, deletes, or when a request starts and finishes, signals fire — and any connected receiver runs automatically. They are Django’s implementation of the observer pattern.

Signals shine when you need loose coupling. A notifications app can react to a Post being created without the blog app knowing notifications exist. But signals are also one of the most misused features in Django. This guide covers the practical patterns and the traps.

How the Signal Dispatcher Works

Django’s signal mechanism lives in django.dispatch. Every signal is an instance of Signal. When you call signal.send(sender, **kwargs), the dispatcher loops through connected receivers and calls each one synchronously, in the order they were connected.

# Simplified view of what happens internally
class Signal:
    def __init__(self):
        self.receivers = []

    def connect(self, receiver, sender=None):
        self.receivers.append((receiver, sender))

    def send(self, sender, **kwargs):
        responses = []
        for receiver, filter_sender in self.receivers:
            if filter_sender is None or filter_sender is sender:
                responses.append((receiver, receiver(sender=sender, **kwargs)))
        return responses

Key point: signals are synchronous. A slow receiver blocks the entire request. Keep receivers fast or offload heavy work to a task queue.

Connecting to Built-in Signals

post_save — React After a Model Saves

The most commonly used signal. It fires after Model.save() completes.

# profiles/signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.contrib.auth.models import User
from .models import Profile


@receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs):
    """Create a Profile automatically when a new User is created."""
    if created:
        Profile.objects.create(user=instance)


@receiver(post_save, sender=User)
def save_user_profile(sender, instance, **kwargs):
    """Save the Profile whenever the User is saved."""
    instance.profile.save()

The created boolean tells you whether this is an INSERT (True) or an UPDATE (False). Always check it — otherwise you run creation logic on every save.

pre_delete — Run Cleanup Before Deletion

pre_delete fires before Django removes the row. Use it for cleanup that CASCADE doesn’t handle, like deleting files from storage.

# documents/signals.py
from django.db.models.signals import pre_delete
from django.dispatch import receiver
from .models import Document


@receiver(pre_delete, sender=Document)
def cleanup_document_file(sender, instance, **kwargs):
    """Delete the uploaded file from storage before the row is removed."""
    if instance.file:
        instance.file.delete(save=False)

Use pre_delete rather than post_delete when you need access to the instance’s data (like file paths or related objects) before they are gone.

Registering Signals Properly

The most common mistake with signals is forgetting to import them. Django will never call a receiver that was never connected. The standard pattern uses AppConfig.ready():

# profiles/apps.py
from django.apps import AppConfig


class ProfilesConfig(AppConfig):
    default_auto_field = "django.db.models.BigAutoField"
    name = "profiles"

    def ready(self):
        import profiles.signals  # noqa: F401

Put your receivers in a signals.py file inside the app, and import that module in ready(). The noqa comment silences linters that complain about unused imports.

Make sure your INSTALLED_APPS entry uses the config class path or that your app has default_app_config set.

Creating Custom Signals

Built-in signals cover model lifecycle and request/response events. For domain events — “order completed”, “subscription renewed” — create your own.

# orders/signals.py
from django.dispatch import Signal

# Define the signal
order_completed = Signal()  # No arguments needed in modern Django

Send the signal from your business logic:

# orders/services.py
from .signals import order_completed


def complete_order(order):
    order.status = "completed"
    order.save()

    # Fire the signal
    order_completed.send(sender=order.__class__, order=order)

Connect receivers in other apps:

# notifications/signals.py
from django.dispatch import receiver
from orders.signals import order_completed


@receiver(order_completed)
def send_order_confirmation(sender, order, **kwargs):
    """Send an email when any order completes."""
    send_confirmation_email(order.customer.email, order)
# analytics/signals.py
from django.dispatch import receiver
from orders.signals import order_completed


@receiver(order_completed)
def track_order_event(sender, order, **kwargs):
    """Log the order event to the analytics pipeline."""
    track_event("order_completed", {"order_id": order.id, "total": str(order.total)})

Both receivers fire when order_completed.send() is called. Neither app needs to know about the other.

Common Pitfalls and How to Avoid Them

1. Signals Firing Twice

If you import signals.py both in ready() and at the top of models.py, receivers connect twice. Use the dispatch_uid parameter as a safety net:

@receiver(post_save, sender=User, dispatch_uid="create_profile_once")
def create_user_profile(sender, instance, created, **kwargs):
    if created:
        Profile.objects.create(user=instance)

The dispatch_uid string ensures the receiver is only registered once, no matter how many times the module is imported.

2. Signals Don’t Fire on bulk_create or bulk_update

Django’s QuerySet.bulk_create() and bulk_update() skip the save() method entirely — and therefore skip post_save. If you depend on signals for data integrity, you need to handle bulk operations separately:

# After bulk_create, manually send signals or call your logic
users = User.objects.bulk_create(user_list)
for user in users:
    create_user_profile(sender=User, instance=user, created=True)

3. Circular Imports

Signal files often import models from other apps. If those apps also import from yours, you get circular imports. Fix this by using lazy references:

from django.dispatch import receiver
from django.db.models.signals import post_save


@receiver(post_save, sender="auth.User")  # String reference instead of import
def create_profile(sender, instance, created, **kwargs):
    if created:
        from profiles.models import Profile  # Lazy import inside the function
        Profile.objects.create(user=instance)

4. Slow Receivers Block Requests

Since signals are synchronous, a receiver that sends an email or calls an external API adds its latency to the request. Offload heavy work:

@receiver(order_completed)
def send_order_confirmation(sender, order, **kwargs):
    # Don't send the email here — queue it
    from .tasks import send_confirmation_email_task
    send_confirmation_email_task.delay(order.id)

Signals vs Overriding save() vs Service Layer

Use signals when the sender shouldn’t know about the receiver. An auth.User model doesn’t need to know about your Profile app.

Use overriding save() when the logic is tightly coupled to the model and you control the model class. Validation, computed fields, and denormalization often belong here.

Use a service layer when you need explicit, testable workflows. Services are easier to debug than signals because the call stack is visible.

# Prefer this for complex workflows
def register_user(email, password):
    user = User.objects.create_user(email=email, password=password)
    Profile.objects.create(user=user)
    send_welcome_email(user.email)
    track_event("user_registered", {"user_id": user.id})
    return user

The service function is explicit, testable, and debuggable. Every step is visible in one place.

Testing Signal Receivers

Disconnect signals during tests when they cause unwanted side effects, or test them explicitly:

from django.test import TestCase
from django.contrib.auth.models import User
from profiles.models import Profile


class ProfileSignalTest(TestCase):
    def test_profile_created_on_user_creation(self):
        user = User.objects.create_user(username="alice", password="testpass123")
        self.assertTrue(Profile.objects.filter(user=user).exists())

    def test_profile_not_duplicated_on_user_update(self):
        user = User.objects.create_user(username="bob", password="testpass123")
        user.first_name = "Bob"
        user.save()
        self.assertEqual(Profile.objects.filter(user=user).count(), 1)

Summary

Signals are a powerful decoupling mechanism when used deliberately. Stick to these guidelines: register in AppConfig.ready(), use dispatch_uid to prevent duplicates, keep receivers fast, and prefer explicit service functions for complex workflows. Reserve signals for cross-app events where the sender genuinely shouldn’t know about the receiver.