Signals

Difficulty: Advanced

Django signals allow decoupled applications to get notified when certain actions occur. Signals are a form of the observer pattern - senders emit signals, and receivers (callback functions) respond to them. This allows one part of your application to react to events in another part without direct coupling.

Django provides several built-in signals. pre_save and post_save fire before and after a model's save() method. pre_delete and post_delete fire before and after deletion. m2m_changed fires when a ManyToManyField is modified. request_started and request_finished fire at the beginning and end of HTTP requests. user_logged_in, user_logged_out, and user_login_failed fire during authentication events.

Receivers are functions decorated with @receiver that respond to signals. The receiver function receives the signal sender (the model class), the instance being saved/deleted, and additional keyword arguments depending on the signal. For post_save, the 'created' argument indicates whether the instance is new (True) or being updated (False).

Signal receivers should be imported when the app is ready. The standard pattern is to define receivers in a signals.py file in your app and import it in the app's AppConfig.ready() method. This ensures signals are connected when Django starts up.

Common use cases for signals include: creating related objects automatically (e.g., creating a Profile when a User is created), sending notifications (email when an order status changes), updating denormalized data (recalculating totals when order items change), logging changes, and invalidating caches.

Signals have trade-offs. They decouple code but can make the flow harder to follow - when you save a model, it is not obvious from the view code that signal receivers will also run. Overusing signals can lead to hidden side effects and debugging difficulties. Consider using explicit method calls or model method overrides for critical business logic, and reserve signals for truly decoupled concerns.

Code examples

post_save Signal - Auto-Create Profile

# accounts/signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.conf import settings
from .models import Profile

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

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

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

class AccountsConfig(AppConfig):
    default_auto_field = 'django.db.models.BigAutoField'
    name = 'accounts'

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

post_save fires after a model is saved. 'created=True' means a new object was created (INSERT), False means an update. Import signals in AppConfig.ready() to ensure they are connected when Django starts.

pre_save Signal - Auto-Generate Slug

from django.db.models.signals import pre_save
from django.dispatch import receiver
from django.utils.text import slugify
from .models import Article

@receiver(pre_save, sender=Article)
def auto_slug(sender, instance, kwargs):
    """Generate slug from title if not set."""
    if not instance.slug:
        instance.slug = slugify(instance.title)
        # Ensure uniqueness
        original_slug = instance.slug
        counter = 1
        while Article.objects.filter(slug=instance.slug).exclude(pk=instance.pk).exists():
            instance.slug = f'{original_slug}-{counter}'
            counter += 1

pre_save fires before saving, so you can modify the instance before it hits the database. This signal auto-generates a unique slug from the title. The while loop handles duplicate slugs by appending a counter.

Custom Signals

# orders/signals.py
import django.dispatch

# Define custom signal
order_completed = django.dispatch.Signal()

# Emit the signal in a view or model method
# orders/views.py
from .signals import order_completed

def complete_order(request, pk):
    order = get_object_or_404(Order, pk=pk)
    order.status = 'completed'
    order.save()

    # Emit the signal
    order_completed.send(
        sender=Order,
        order=order,
        user=request.user,
    )
    return redirect('order-detail', pk=pk)

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

@receiver(order_completed)
def send_order_confirmation(sender, order, user, kwargs):
    """Send confirmation email when order is completed."""
    send_mail(
        subject=f'Order #{order.pk} Confirmed',
        message=f'Your order has been completed.',
        from_email='noreply@example.com',
        recipient_list=[user.email],
    )

@receiver(order_completed)
def update_inventory(sender, order, kwargs):
    """Update inventory when order is completed."""
    for item in order.items.all():
        item.product.stock -= item.quantity
        item.product.save()

Custom signals decouple modules. The orders app emits order_completed without knowing who listens. The notifications app and inventory system independently react. Multiple receivers can respond to the same signal.

post_delete and m2m_changed

from django.db.models.signals import post_delete, m2m_changed
from django.dispatch import receiver
from .models import Article, ProductImage
import os

@receiver(post_delete, sender=ProductImage)
def delete_image_file(sender, instance, kwargs):
    """Delete the physical file when a ProductImage is deleted."""
    if instance.image:
        if os.path.isfile(instance.image.path):
            os.remove(instance.image.path)

@receiver(m2m_changed, sender=Article.tags.through)
def tags_changed(sender, instance, action, pk_set, kwargs):
    """Log when tags are added or removed from an article."""
    if action == 'post_add':
        print(f'Tags {pk_set} added to "{instance.title}"')
    elif action == 'post_remove':
        print(f'Tags {pk_set} removed from "{instance.title}"')
    elif action == 'post_clear':
        print(f'All tags cleared from "{instance.title}"')

post_delete is useful for cleaning up files or related resources. m2m_changed fires when ManyToManyField relationships change, with action indicating what happened: pre_add, post_add, pre_remove, post_remove, pre_clear, post_clear.

Key points

Concepts covered

Signal, receiver, pre_save, post_save, m2m_changed, Custom Signals