Django signals cheatsheet.

Built-in signals

from django.db.models.signals import (
    pre_save, post_save,
    pre_delete, post_delete,
    pre_init, post_init,
    pre_migrate, post_migrate,
    m2m_changed,
)
from django.dispatch import receiver

Receiver

@receiver(post_save, sender=Post)
def post_post_save(sender, instance, created, **kwargs):
    if created:
        print(f"new post: {instance.id}")

Register in apps.py

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

class BlogConfig(AppConfig):
    default_auto_field = "django.db.models.BigAutoField"
    name = "blog"
    
    def ready(self):
        from . import signals       # import to register

Or define receivers directly in signals.py and import in ready().

post_save

@receiver(post_save, sender=Post)
def update_search_index(sender, instance, created, update_fields, **kwargs):
    if update_fields and "body" not in update_fields:
        return
    SearchIndex.update(instance)

pre_save

@receiver(pre_save, sender=Post)
def normalize_slug(sender, instance, **kwargs):
    if not instance.slug:
        instance.slug = slugify(instance.title)

post_delete

@receiver(post_delete, sender=Post)
def cleanup_files(sender, instance, **kwargs):
    if instance.image:
        instance.image.delete(save=False)

m2m_changed

@receiver(m2m_changed, sender=Post.tags.through)
def tags_changed(sender, instance, action, pk_set, **kwargs):
    if action == "post_add":
        for tag_id in pk_set:
            update_tag_count(tag_id)

Actions: pre_add, post_add, pre_remove, post_remove, pre_clear, post_clear.

Custom signal

# signals.py
import django.dispatch

post_published = django.dispatch.Signal()

Send:

def publish(post):
    post.published = True
    post.save()
    post_published.send(sender=Post, instance=post)

Receive:

@receiver(post_published)
def notify(sender, instance, **kwargs):
    notify_subscribers(instance)

Connecting without decorator

def my_handler(sender, **kwargs): ...

post_save.connect(my_handler, sender=Post)
post_save.disconnect(my_handler, sender=Post)

Specific sender

@receiver(post_save, sender=Post)        # only Post saves
@receiver(post_save)                     # ALL saves

dispatch_uid (prevent duplicate)

@receiver(post_save, sender=Post, dispatch_uid="blog.post_save_unique")
def handler(...):
    ...

Prevents the same handler being registered twice (e.g., via dev reload).

transaction.on_commit

from django.db import transaction

@receiver(post_save, sender=Order)
def order_saved(sender, instance, created, **kwargs):
    if created:
        transaction.on_commit(lambda: process_order.delay(instance.id))

Without on_commit, the Celery task may try to read the row before COMMIT.

When NOT to use signals

Signals are powerful but easy to abuse:

  • Avoid for business logic — hard to find, hard to test.
  • Don’t use to enforce invariants — call a service method instead.
  • Avoid cross-app side effects — coupling without imports.

Better: explicit method calls.

# BAD: side effect via signal
@receiver(post_save, sender=Post)
def index_post(...):
    SearchIndex.update(instance)

# GOOD: explicit
class Post(models.Model):
    def save(self, *args, **kwargs):
        super().save(*args, **kwargs)
        SearchIndex.update(self)

Or a service:

def publish_post(post):
    post.published = True
    post.save()
    SearchIndex.update(post)
    notify_subscribers(post)

Signals + bulk operations

⚠️ Signals don’t fire on bulk_create, bulk_update, qs.update(), qs.delete() by default.

Post.objects.filter(...).update(views=F("views") + 1)
# No post_save signal!

If you need signals, use a loop with .save().

App ready vs module-level

Register signals in apps.py:ready(), NOT module-level. Avoids early imports + circular imports.

Built-in signals worth knowing

from django.contrib.auth.signals import user_logged_in, user_logged_out, user_login_failed
from django.core.signals import request_started, request_finished, got_request_exception
from django.test.signals import setting_changed

Common mistakes

  • Heavy work in signal handler — blocks request. Use Celery.
  • Forgetting dispatch_uid → handler runs twice.
  • Signal handler in wrong file (not loaded).
  • Triggering signals from loops without batching.
  • pre_save modifying pk field on existing object — chaos.

Read this next

If you want my signals-as-service refactor patterns, they’re at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .