Django Signals - Complete Guide

A complete guide to Django signals covering built-in signals, creating custom signals, and applying best practices. Explore real-world use cases like sending emails and logging activity, understand pitfalls, and learn when to use or avoid signals in your Django projects.

1

Introduction to Django Signals


Django signals are a powerful way to let different parts of your application communicate with each other. They allow one piece of code (the sender) to notify other pieces of code (the receivers) when certain actions occur, without tightly coupling the logic. This makes your application more modular and easier to maintain.

In simple words: signals act like “listeners.” For example, when a user is created, a signal can automatically trigger code to send them a welcome email-without modifying the user model itself.

2

How Signals Work in Django


Signals work by connecting senders and receivers. A sender is the piece of code that emits a signal when something happens (like saving a model), while a receiver is a function that reacts to it. Django uses a dispatcher to manage these connections so that multiple receivers can respond to the same event.


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 after_user_created(sender, instance, created, **kwargs):
    if created:
        print(f"New user created: {instance.username}")
        # you could send a welcome email or send a notification to the admin 
        

In this example, whenever a new User object is created, the receiver function is triggered automatically.

3

Overview of Built-in Signals


Django provides a variety of built-in signals to help you hook into different parts of the framework. Below is a categorized overview showing where each group of signals applies. Think of it as a quick reference map before diving deeper into details and examples.

  • Model lifecycle signals
    • pre_init, post_init
    • pre_save, post_save
    • pre_delete, post_delete
    • m2m_changed
    • class_prepared
  • HTTP request lifecycle signals
    • request_started
    • request_finished
    • got_request_exception
  • Authentication signals
    • user_logged_in
    • user_logged_out
    • user_login_failed
  • Migration signals
    • pre_migrate
    • post_migrate
  • Database connection signals
    • connection_created
4

Built-in Model Lifecycle Signals


These signals hook into model initialization, save, delete, and many-to-many changes. Each block below includes two practical scenarios to show different angles of usage. Keep handlers fast, idempotent, and move heavy work to tasks.

pre_init - before model __init__ runs

Use to normalize incoming kwargs or inject defaults before the instance is constructed.

  • Scenario 1 - Strip and normalize incoming title casing before instance exists.
  • Scenario 2 - Provide a default language if not supplied.

  from django.db.models.signals import pre_init
  from django.dispatch import receiver
  from .models import Article
  
  @receiver(pre_init, sender=Article)
  def article_pre_init_normalize(sender, *args, **kwargs):
      title = kwargs.get("title")
      if isinstance(title, str):
          kwargs["title"] = title.strip().title()
  
  @receiver(pre_init, sender=Article)
  def article_pre_init_defaults(sender, *args, **kwargs):
      kwargs.setdefault("lang", "en")
  

post_init - after model instance is created

Use to cache originals for change detection or compute transient attributes.

  • Scenario 1 - Snapshot original title for later comparison on save.
  • Scenario 2 - Compute a derived transient read_time that you do not want persisted.

  from django.db.models.signals import post_init
  from django.dispatch import receiver
  from .models import Article
  
  @receiver(post_init, sender=Article)
  def article_snapshot(sender, instance, **kwargs):
      instance._orig_title = instance.title
  
  @receiver(post_init, sender=Article)
  def article_compute_transient(sender, instance, **kwargs):
      words = (instance.body or "").split()
      instance._read_time_min = max(1, len(words) // 200)
  

pre_save - before commit to DB

Great for fast validation or generating fields. Avoid network calls.

  • Scenario 1 - Auto-generate slug if missing.
  • Scenario 2 - Validate a field constraint and raise to block save.

  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 article_slugify(sender, instance, **kwargs):
      if not instance.slug and instance.title:
          instance.slug = slugify(instance.title)[:60]
  
  @receiver(pre_save, sender=Article)
  def article_validate_lang(sender, instance, **kwargs):
      if instance.lang not in {"en", "ur", "de", "ar"}:
          raise ValueError("Unsupported language")
  

post_save - after object is saved

Use with transaction.on_commit for side effects to avoid acting on rolled back data.

  • Scenario 1 - On first create send a welcome email.
  • Scenario 2 - On title change trigger a search reindex job.

  from django.db.models.signals import post_save
  from django.dispatch import receiver
  from django.db import transaction
  from django.core.mail import send_mail
  from .models import Article
  
  @receiver(post_save, sender=Article)
  def article_created_send_email(sender, instance, created, **kwargs):
      if created and instance.author and instance.author.email:
          transaction.on_commit(lambda: send_mail(
              "Thanks for publishing",
              f"Your article {instance.title} is live.",
              "[email protected]",
              [instance.author.email],
              fail_silently=True
          ))
  
  @receiver(post_save, sender=Article)
  def article_title_changed_reindex(sender, instance, created, **kwargs):
      if not created and hasattr(instance, "_orig_title") and instance._orig_title != instance.title:
          transaction.on_commit(lambda: print("Queue search reindex for", instance.pk))
  

pre_delete - before object removal

Ideal for cleaning external resources that must be removed before the DB row is gone.

  • Scenario 1 - Delete an attached file from storage.
  • Scenario 2 - Prevent delete if a compliance flag is set.

  from django.db.models.signals import pre_delete
  from django.dispatch import receiver
  from django.core.files.storage import default_storage
  from .models import Article
  
  @receiver(pre_delete, sender=Article)
  def article_cleanup_file(sender, instance, **kwargs):
      if getattr(instance, "attachment_path", None):
          try:
              default_storage.delete(instance.attachment_path)
          except Exception:
              pass
  
  @receiver(pre_delete, sender=Article)
  def article_guard_delete(sender, instance, **kwargs):
      if getattr(instance, "locked", False):
          raise ValueError("This article is locked and cannot be deleted")
  

post_delete - after object removal

Great for audit logs and counters that can be updated after deletion.

  • Scenario 1 - Write an audit log entry.
  • Scenario 2 - Decrement a user stats counter.

  from django.db.models.signals import post_delete
  from django.dispatch import receiver
  from django.db import transaction
  from .models import Article, UserStats
  
  @receiver(post_delete, sender=Article)
  def article_audit_log(sender, instance, **kwargs):
      transaction.on_commit(lambda: print(f"Audit: article {instance.pk} deleted"))
  
  @receiver(post_delete, sender=Article)
  def article_decrement_counter(sender, instance, **kwargs):
      if instance.author_id:
          def _update():
              UserStats.objects.filter(user_id=instance.author_id).update(articles_count=models.F("articles_count") - 1)
          transaction.on_commit(_update)
  

m2m_changed - many-to-many updates

Fires on add, remove, or clear operations on a ManyToMany field.

  • Scenario 1 - Update denormalized tag_count on the article.
  • Scenario 2 - Sync tag usage stats after tags are added.

  from django.db.models.signals import m2m_changed
  from django.dispatch import receiver
  from django.db import transaction, models
  from .models import Article, Tag
  
  @receiver(m2m_changed, sender=Article.tags.through)
  def article_tag_denorm(sender, instance, action, pk_set, **kwargs):
      if action in {"post_add", "post_remove", "post_clear"}:
          def _update():
              instance.tag_count = instance.tags.count()
              instance.save(update_fields=["tag_count"])
          transaction.on_commit(_update)
  
  @receiver(m2m_changed, sender=Article.tags.through)
  def tag_usage_stats(sender, instance, action, pk_set, **kwargs):
      if action == "post_add" and pk_set:
          def _update():
              Tag.objects.filter(pk__in=pk_set).update(use_count=models.F("use_count") + 1)
          transaction.on_commit(_update)
  

class_prepared - model class is ready

Runs once per model class after Django constructs it. Useful for dynamic registration or inspection.

  • Scenario 1 - Log or verify required fields exist for a plugin.
  • Scenario 2 - Dynamically attach a helper method for specific models.

  from django.db.models.signals import class_prepared
  from django.dispatch import receiver
  
  @receiver(class_prepared)
  def verify_fields(sender, **kwargs):
      if sender.__name__ == "Article":
          needed = {"title", "slug"}
          has = {f.name for f in sender._meta.get_fields()}
          missing = needed - has
          if missing:
              print("Article missing fields:", missing)
  
  @receiver(class_prepared)
  def attach_helper(sender, **kwargs):
      if sender.__name__ == "Article":
          def short_title(self):
              return (self.title or "")[:30]
          setattr(sender, "short_title", short_title)
  
5

Built-in HTTP Request Signals


These signals fire around the request cycle. They are useful for lightweight cross-cutting concerns like latency metrics, request-scoped storage, and error notifications. Keep handlers fast and avoid blocking network calls in the hot path. For heavy work, enqueue a background task.

request_started - when Django begins handling a request

Fires as soon as Django starts processing an HTTP request. Good for initializing per-request state, timers, correlation ids, or tracing context.

  • Scenario 1 - Start a high resolution timer to measure total request duration.
  • Scenario 2 - Generate and attach a correlation id for request logging across services.

  from django.core.signals import request_started
  from django.dispatch import receiver
  import time, uuid
  from threading import local
  
  _request_ctx = local()
  
  @receiver(request_started)
  def on_request_started(sender, **kwargs):
      # Scenario 1 - start timer
      _request_ctx.started_at = time.perf_counter()
      # Scenario 2 - correlation id
      _request_ctx.correlation_id = str(uuid.uuid4())
  

request_finished - after Django sends the response

Fires when Django has finished handling the request. Useful for emitting metrics, cleaning threadlocals, and flushing buffers. Avoid doing expensive work here.

  • Scenario 1 - Compute total latency and print or push a metric.
  • Scenario 2 - Clear per-request context to prevent memory leaks in long running workers.

  from django.core.signals import request_finished
  from django.dispatch import receiver
  import time
  
  # Reuse _request_ctx from request_started example
  @receiver(request_finished)
  def on_request_finished(sender, **kwargs):
      start = getattr(_request_ctx, "started_at", None)
      if start is not None:
          duration_ms = (time.perf_counter() - start) * 1000
          print(f"[metric] request_total_ms={duration_ms:.1f} corr_id={getattr(_request_ctx, 'correlation_id', '-')}")
      # Scenario 2 - cleanup
      for attr in ("started_at", "correlation_id"):
          if hasattr(_request_ctx, attr):
              delattr(_request_ctx, attr)
  

got_request_exception - when an exception occurs during a request

Fires if an unhandled exception bubbles up while processing a request. Use it to record minimal context for diagnostics. Do not swallow exceptions. Avoid heavy synchronous I/O here.

  • Scenario 1 - Log the path, method, and correlation id to help trace incidents.
  • Scenario 2 - Queue an error report to a background worker instead of sending synchronously.

  from django.core.signals import got_request_exception
  from django.dispatch import receiver
  from django.db import transaction
  
  def queue_error_report(payload: dict) -> None:
      # Placeholder for a task enqueue, e.g., Celery delay()
      print("[task] queued error report:", payload)
  
  @receiver(got_request_exception)
  def on_request_exception(sender, request, **kwargs):
      path = getattr(request, "path", "-")
      method = getattr(request, "method", "-")
      corr_id = getattr(_request_ctx, "correlation_id", "-")
      # Scenario 1 - minimal structured log
      print(f"[error] path={path} method={method} corr_id={corr_id}")
      # Scenario 2 - enqueue after DB commit if inside a transaction
      try:
          transaction.on_commit(lambda: queue_error_report({"path": path, "method": method, "corr_id": corr_id}))
      except Exception:
          # If no active transaction, just queue directly but keep it cheap
          queue_error_report({"path": path, "method": method, "corr_id": corr_id})
  
6

Built-in Database Connection Signals


Django provides a signal called connection_created which fires every time a new database connection is opened. This is useful if you need to run setup commands on the database session automatically.

connection_created - when a DB connection is established

Example: If you are using SQLite in development, you can automatically enable foreign key support whenever Django creates a new connection.


  from django.db.backends.signals import connection_created
  from django.dispatch import receiver
  
  @receiver(connection_created)
  def setup_sqlite_foreign_keys(sender, connection, **kwargs):
      if connection.vendor == "sqlite":
          cursor = connection.cursor()
          cursor.execute("PRAGMA foreign_keys = ON;")
  
7

Built-in Authentication Signals


Django provides signals for user authentication events like login, logout, and login failures. These are helpful for tracking user activity or adding security-related features.

user_logged_in - when a user logs in

Example: Record the last login IP address of the user whenever they successfully log in.


  from django.contrib.auth.signals import user_logged_in
  from django.dispatch import receiver
  
  @receiver(user_logged_in)
  def track_login_ip(sender, request, user, **kwargs):
      user.last_login_ip = request.META.get("REMOTE_ADDR")
      user.save(update_fields=["last_login_ip"])
  

user_logged_out - when a user logs out

Example: Write a log message when a user logs out of the system.


  from django.contrib.auth.signals import user_logged_out
  from django.dispatch import receiver
  
  @receiver(user_logged_out)
  def log_user_logout(sender, request, user, **kwargs):
      print(f"User {user.username} has logged out.")
  

user_login_failed - when login attempt fails

Example: Track failed login attempts to help detect brute force attacks.


  from django.contrib.auth.signals import user_login_failed
  from django.dispatch import receiver
  
  @receiver(user_login_failed)
  def login_failed_alert(sender, credentials, request, **kwargs):
      print(f"Failed login attempt for username: {credentials.get('username')}")
  
8

Built-in Migration Signals


Migration signals run when Django applies or prepares migrations. These are especially useful for creating default data, groups, or permissions automatically after database setup.

pre_migrate - before migrations run

Example: Print a log message before migrations of a specific app are executed.


  from django.db.models.signals import pre_migrate
  from django.dispatch import receiver
  
  @receiver(pre_migrate)
  def before_migrate(sender, app_config, **kwargs):
      if app_config.label == "myapp":
          print("Preparing to migrate myapp...")
  

post_migrate - after migrations complete

Example: Automatically create a default user group with permissions after migrations finish.


  from django.db.models.signals import post_migrate
  from django.dispatch import receiver
  from django.contrib.auth.models import Group
  
  @receiver(post_migrate)
  def create_default_group(sender, app_config, **kwargs):
      if app_config.label == "myapp":
          Group.objects.get_or_create(name="Editors")
  
9

Other Framework Signals


Django also exposes a couple of handy signals in its testing utilities. These are useful when you want to react to test-only events such as settings changes or template rendering during a test run.

setting_changed - when a setting is changed in tests

Easy example: when TIME_ZONE is changed in a test (using override_settings), clear a cached formatter so tests stay consistent.


  from django.test.signals import setting_changed
  from django.dispatch import receiver
  
  _cached_dt_formatter = None
  
  @receiver(setting_changed)
  def clear_formatter_on_tz_change(sender, setting, value, enter, **kwargs):
      # Fires when a setting is changed in tests
      global _cached_dt_formatter
      if setting == "TIME_ZONE":
          _cached_dt_formatter = None  # reset any cached timezone-aware utilities
  

template_rendered - when a template is rendered in tests

Easy example: count how many templates your view renders during a test to catch accidental extra includes.


  from django.test.signals import template_rendered
  from django.dispatch import receiver
  
  templates_rendered = 0
  
  @receiver(template_rendered)
  def count_templates(sender, template, context, **kwargs):
      global templates_rendered
      templates_rendered += 1  # inspect 'template' or 'context' if you need
  
10

Creating and Using Custom Signals


Custom signals let you model your own domain events - for example order_paid or profile_completed. They help decouple features like notifications, analytics, and auditing from your core business logic. Keep handlers lightweight and register them in apps.py to avoid import issues.

Step 1 - Define a custom signal

Create a dedicated signals.py in your app and define the signal with any useful providing_args-like context (just pass kwargs).


  # myapp/signals.py
  from django.dispatch import Signal
  
  # Fired when a payment is successfully captured for an order
  order_paid = Signal()  # kwargs: order_id, amount, currency, paid_at
  

Step 2 - Send the signal from your domain logic

In your service or model method, emit the event after the database changes are successful. Prefer transaction.on_commit so handlers only run if the DB commit succeeds.


  # myapp/services/payments.py
  from django.db import transaction
  from django.utils import timezone
  from myapp.signals import order_paid
  
  def capture_payment(order, amount, currency="USD"):
      # 1) run gateway call, 2) update order status/records, 3) on commit fire event
      order.is_paid = True
      order.paid_at = timezone.now()
      order.save(update_fields=["is_paid", "paid_at"])
      transaction.on_commit(lambda: order_paid.send(
          sender=order.__class__,
          order_id=order.pk,
          amount=amount,
          currency=currency,
          paid_at=order.paid_at
      ))
  

Step 3 - Write receivers (lightweight handlers)

Attach one or more receivers. Keep work minimal - enqueue emails/notifications to a task queue for reliability and speed.


  # myapp/receivers.py
  from django.dispatch import receiver
  from django.core.mail import send_mail
  from myapp.signals import order_paid
  
  def enqueue_analytics_event(event: dict) -> None:
      # placeholder - push to Celery/Redis/etc.
      print("[analytics]", event)
  
  @receiver(order_paid)
  def send_receipt_email(sender, order_id, amount, currency, paid_at, **kwargs):
      # Very small synchronous work; real projects should queue this
      try:
          send_mail(
              "Payment received",
              f"Thanks! We received {amount} {currency} for order #{order_id} on {paid_at}.",
              "[email protected]",
              ["[email protected]"],
              fail_silently=True
          )
      except Exception:
          pass
  
  @receiver(order_paid)
  def track_payment_analytics(sender, order_id, amount, currency, paid_at, **kwargs):
      enqueue_analytics_event({
          "type": "order_paid",
          "order_id": order_id,
          "amount": amount,
          "currency": currency,
          "paid_at": str(paid_at)
      })
  

Step 4 - Wire receivers in apps.py via ready()

Import your receivers once per process inside AppConfig.ready() to ensure they are registered without causing circular imports.


  # myapp/apps.py
  from django.apps import AppConfig
  
  class MyAppConfig(AppConfig):
      default_auto_field = "django.db.models.BigAutoField"
      name = "myapp"
  
      def ready(self):
          # Import receives to connect them
          from . import receivers  # noqa: F401
  

Step 5 - Activate your AppConfig in settings

Use the dotted AppConfig path in INSTALLED_APPS so ready() runs.


  # settings.py
  INSTALLED_APPS = [
      # ...
      "myapp.apps.MyAppConfig",
  ]
  

Quick recap: define the signal, send it from your domain layer after commit, keep receivers light, and wire them in apps.py. This pattern keeps code decoupled, testable, and production-safe.

11

Real-World Use Cases


Practical examples showing how signals help you decouple side effects from core logic. Each scenario is intentionally simple - you can replace print with real services like email, logging, analytics, or task queues.

Send welcome email after user registration

When a new user account is created, send a simple welcome email. We use transaction.on_commit so the email only sends after the database commit succeeds.


  from django.contrib.auth.models import User
  from django.db.models.signals import post_save
  from django.dispatch import receiver
  from django.core.mail import send_mail
  from django.db import transaction
  
  @receiver(post_save, sender=User)
  def send_welcome_email(sender, instance, created, **kwargs):
      if created and instance.email:
          transaction.on_commit(lambda: send_mail(
              subject="Welcome to our site",
              message=f"Hi {instance.username}, thanks for joining.",
              from_email="[email protected]",
              recipient_list=[instance.email],
              fail_silently=True
          ))
  

Write audit log when a model changes

Track who edited an article and what changed. Cache the original fields in post_init, then compare inside post_save.


  from django.db.models.signals import post_init, post_save
  from django.dispatch import receiver
  from django.db import transaction
  from .models import Article  # fields: title, body, updated_by
  
  @receiver(post_init, sender=Article)
  def snapshot_article(sender, instance, **kwargs):
      instance._orig_title = instance.title
      instance._orig_body = instance.body
  
  @receiver(post_save, sender=Article)
  def audit_article_update(sender, instance, created, **kwargs):
      if created:
          return
      changes = []
      if instance._orig_title != instance.title:
          changes.append("title")
      if instance._orig_body != instance.body:
          changes.append("body")
      if changes:
          transaction.on_commit(lambda: print(
              f"[audit] article={instance.pk} updated_by={instance.updated_by_id} changes={changes}"
          ))
  

Auto-generate slug before save

Keep slugs consistent by generating them on create or when the title is first set. Keep logic quick in pre_save.


  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 ensure_slug(sender, instance, **kwargs):
      if not instance.slug and instance.title:
          instance.slug = slugify(instance.title)[:60]
  

Keep denormalized tag counts updated

When tags are added or removed from an article, update a cached counter on the article after commit.


  from django.db.models.signals import m2m_changed
  from django.dispatch import receiver
  from django.db import transaction
  from .models import Article
  
  @receiver(m2m_changed, sender=Article.tags.through)
  def update_tag_count(sender, instance, action, **kwargs):
      if action in {"post_add", "post_remove", "post_clear"}:
          transaction.on_commit(lambda: (
              setattr(instance, "tag_count", instance.tags.count()),
              instance.save(update_fields=["tag_count"])
          ))
  

Trigger a background job after save

Offload heavy work like generating a PDF or sending multiple emails. Signal only queues the job.


  from django.db.models.signals import post_save
  from django.dispatch import receiver
  from django.db import transaction
  from .models import Invoice
  
  def enqueue_pdf_job(invoice_id: int) -> None:
      print("[task] generate_pdf for invoice", invoice_id)  # replace with Celery or RQ
  
  @receiver(post_save, sender=Invoice)
  def generate_invoice_pdf(sender, instance, created, **kwargs):
      if created:
          transaction.on_commit(lambda: enqueue_pdf_job(instance.pk))
  

Clean up files when an object is deleted

Remove associated file from storage when the object is deleted to prevent orphaned files.


  from django.db.models.signals import pre_delete
  from django.dispatch import receiver
  from django.core.files.storage import default_storage
  from .models import Document  # has file_path field
  
  @receiver(pre_delete, sender=Document)
  def delete_document_file(sender, instance, **kwargs):
      if instance.file_path:
          try:
              default_storage.delete(instance.file_path)
          except Exception:
              pass
  
12

Best Practices for Django Signals


Use signals to decouple side effects, not to hide core business rules. Keep handlers tiny, safe, and predictable. Below are field-tested practices for production apps.

  • Register receivers in AppConfig.ready() - import your signals.py or receivers.py inside ready() to avoid import loops and duplicate connections.
  • Keep handlers lightweight - no heavy I/O. Queue background jobs instead. Favor transaction.on_commit for side effects.
  • Be idempotent - receivers may run more than once in some workflows. Guard with simple checks or flags.
  • Scope with sender - always provide sender=YourModel to avoid catching unrelated events.
  • Avoid recursion - when saving inside receivers, prefer update_fields to minimize re-triggers and add guards to stop loops.
  • Use dispatch_uid - prevent duplicate connections in multi-import situations.
  • Prefer validations in model or forms - signals are for side effects, not for enforcing domain invariants.
  • Test explicitly - write unit tests for receivers and consider disconnecting or patching signals during tests that do not need them.
  • Log minimally - avoid noisy prints in hot paths. Use structured logs and sample if needed.

  # apps.py - ensure receivers are loaded once per process
  from django.apps import AppConfig
  
  class BlogConfig(AppConfig):
      default_auto_field = "django.db.models.BigAutoField"
      name = "blog"
  
      def ready(self):
          from . import receivers  
  

  # receivers.py - lightweight, scoped, idempotent
  from django.db.models.signals import post_save
  from django.dispatch import receiver
  from django.db import transaction
  from .models import Article
  
  @receiver(post_save, sender=Article, dispatch_uid="article_post_save_notify_v1")
  def notify_index_on_title_change(sender, instance, created, **kwargs):
      # Scope: only Article. Idempotent: only act if the title actually changed.
      if created:
          return
      old = getattr(instance, "_orig_title", None)
      if old and old == instance.title:
          return  # nothing changed - safe no-op
  
      # Heavy work goes to a background job after commit
      transaction.on_commit(lambda: print("queue_reindex", instance.pk))
  

  # Guard against recursion - use update_fields and a simple flag
  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, dispatch_uid="article_slug_autofill_v1")
  def autofill_slug(sender, instance, **kwargs):
      if getattr(instance, "_slug_computed", False):
          return
      if not instance.slug and instance.title:
          instance.slug = slugify(instance.title)[:60]
          instance._slug_computed = True  # guard
          # If you must persist again, do it carefully:
          # instance.save(update_fields=["slug"])
          # But ideally avoid saving inside signals unless absolutely necessary.
  
13

Common Pitfalls and Mistakes


Signals are powerful but easy to misuse. Keep them simple, scoped, and predictable. Below are frequent mistakes plus safe patterns to avoid bugs in production.

  • Stuffing core business rules into signals - put invariants in models, services, or forms. Use signals for side effects only.
  • Doing heavy I/O in hot paths - email, HTTP calls, PDFs, or search indexing should be queued to a background worker.
  • Duplicate receiver registration - happens when modules import multiple times. Use dispatch_uid and wire in apps.py.
  • Acting before transaction commit - if a transaction rolls back, your side effects are wrong. Use transaction.on_commit.
  • Infinite recursion - saving in a receiver can retrigger the same signal. Guard with flags and use update_fields.
  • Expecting signals on bulk ops - bulk_create and .update() do not call save(), so most model signals do not fire.
  • Leaking request state into model signals - model signals should not rely on request. Pass context via services instead.
  • Overbroad receivers - always scope with sender=YourModel to avoid catching unrelated events.

  # Safe side effects - use on_commit and dispatch_uid
  from django.db import transaction
  from django.db.models.signals import post_save
  from django.dispatch import receiver
  from .models import Article
  
  def enqueue_reindex(article_id: int) -> None:
      print("[task] reindex", article_id)
  
  @receiver(post_save, sender=Article, dispatch_uid="article_reindex_after_commit_v1")
  def article_reindex_after_commit(sender, instance, created, **kwargs):
      # Only queue after DB commit to avoid stale work
      transaction.on_commit(lambda: enqueue_reindex(instance.pk))
  

  # Guard against recursion when you must update inside a signal
  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, dispatch_uid="article_autoslug_guard_v1")
  def autoslug_guard(sender, instance, **kwargs):
      if getattr(instance, "_slug_set", False):
          return
      if not instance.slug and instance.title:
          instance.slug = slugify(instance.title)[:60]
          instance._slug_set = True  # guard flag
          # Avoid calling instance.save() here. If needed, use update_fields in a controlled flow.
  
14

When Not to Use Signals


Signals are great for decoupling side effects, but they are not always the right tool. In some cases, they add unnecessary complexity or hide core logic. Here are situations where you should avoid using signals and consider alternatives instead.

  • Core business logic – keep it explicit in models, services, or forms. Don't bury rules in hidden receivers.
  • Critical workflows – payments, inventory, or compliance events should be written in a clear service layer, not hidden behind signals.
  • Simple model changes – prefer overriding save() or delete() if the logic belongs to the model itself.
  • Bulk operations – signals do not fire on bulk_create or .update(). Write explicit logic instead.
  • Test predictability – signals can trigger side effects unexpectedly in tests. Inline service calls make tests more reliable.

  # Better with save() override instead of signals
  from django.db import models
  from django.utils.text import slugify
  
  class Article(models.Model):
      title = models.CharField(max_length=200)
      slug = models.SlugField(blank=True)
  
      def save(self, *args, **kwargs):
          if not self.slug and self.title:
              self.slug = slugify(self.title)[:60]
          super().save(*args, **kwargs)
  

  # Better with an explicit service call for payments
  def capture_payment(order, amount):
      # 1. call payment gateway
      # 2. update order status
      # 3. send receipt email directly here or via task queue
      order.is_paid = True
      order.save(update_fields=["is_paid"])
      print("Payment recorded, email queued")
  
15

Advanced Topics


Signals in Django are synchronous and run in the same process. Use them to trigger lightweight side effects, and hand off heavy work to background workers. Below are practical patterns for async tasks and testing signals safely.

Use signals with background tasks

Pattern: trigger a Celery or RQ job from a signal but only after the transaction commits. This prevents sending jobs for database changes that later roll back.


  # blog/receivers.py
  from django.db.models.signals import post_save
  from django.dispatch import receiver
  from django.db import transaction
  from .models import Article
  
  def enqueue_index(article_id: int) -> None:
      # Replace with celery_task.delay(article_id)
      print("[task] index article", article_id)
  
  @receiver(post_save, sender=Article)
  def article_index_after_commit(sender, instance, created, **kwargs):
      # Keep handler fast - no network calls here
      transaction.on_commit(lambda: enqueue_index(instance.pk))
  

Tip: if you must send multiple jobs, batch them inside the same on_commit callback to reduce queue overhead.

Signals in async contexts

Django signals are synchronous. Even if your view is async, the receiver will still run in the main thread. Keep receivers tiny and offload real async work to a task queue or an async worker that you call after commit.


  # Good pattern in an async project: signal enqueues async-capable worker
  from django.db.models.signals import post_save
  from django.dispatch import receiver
  from django.db import transaction
  from .models import Comment
  
  async def async_notify_service(comment_id: int) -> None:
      # This would run in an async worker, not in the signal
      ...
  
  def enqueue_async_notify(comment_id: int) -> None:
      # Bridge to your async worker (Celery, Dramatiq, custom consumer)
      print("[task] async_notify", comment_id)
  
  @receiver(post_save, sender=Comment)
  def comment_notify_after_commit(sender, instance, created, **kwargs):
      if created:
          transaction.on_commit(lambda: enqueue_async_notify(instance.pk))
  

Testing signals effectively

Test receivers in isolation and keep tests predictable. You can temporarily disconnect receivers or assert side effects triggered after commit.


  # tests/test_signals.py
  from django.test import TestCase
  from django.db.models.signals import post_save
  from django.dispatch import receiver
  from django.db import transaction
  from blog.models import Article
  
  called = {"count": 0}
  
  @receiver(post_save, sender=Article, dispatch_uid="test_article_hook")
  def _hook(sender, instance, created, **kwargs):
      transaction.on_commit(lambda: called.__setitem__("count", called["count"] + 1))
  
  class TestArticleSignals(TestCase):
      def test_post_save_triggers_after_commit(self):
          a = Article.objects.create(title="Hello")
          # Commit already happened at end of create
          self.assertEqual(called["count"], 1)
  
      def tearDown(self):
          # optional - disconnect to keep global state clean in large suites
          post_save.disconnect(_hook, sender=Article, dispatch_uid="test_article_hook")
  

  # Option - temporarily disconnect a noisy production receiver during a test
  from django.test import TestCase
  from django.db.models.signals import post_save
  from blog.receivers import article_index_after_commit
  from blog.models import Article
  
  class TestWithoutIndexing(TestCase):
      def setUp(self):
          post_save.disconnect(article_index_after_commit, sender=Article)
  
      def tearDown(self):
          post_save.connect(article_index_after_commit, sender=Article)
  
      def test_create_without_side_effects(self):
          Article.objects.create(title="No indexing during this test")
  
16

Conclusion & Key Takeaways


Django signals are a powerful tool for decoupling side effects from your core logic. Used well, they help keep code modular and clean. Misused, they can hide critical logic and introduce subtle bugs. The key is knowing when to use them-and when not to.

  • Use signals for side effects like emails, logs, counters, and notifications.
  • Always keep receivers fast, idempotent, and scoped to a specific model.
  • Register signals in apps.py to avoid duplicate connections.
  • Prefer transaction.on_commit for reliable post-save side effects.
  • Avoid burying core business rules inside signals-use explicit service layers instead.
  • Test receivers directly, and disconnect noisy ones in test setups if needed.

With these practices, signals can help you build cleaner, event-driven Django apps while keeping your project maintainable in the long run.

17

Frequently Asked Questions


Signals themselves are fast, but heavy logic inside receivers can slow down requests. Keep handlers light and use background tasks for big jobs.

Middleware works globally on every request/response, while signals trigger on specific events like saving a model. Use signals for targeted side effects.

Register in apps.py, use dispatch_uid, keep handlers idempotent, and only add side effects. DonÆt hide core business logic inside signals.

No, most signals like pre_save and post_save donÆt trigger on bulk operations. Write explicit logic when using bulk queries.

Avoid signals for core workflows, critical business rules, or bulk data changes. Use explicit service methods or model save overrides instead.

Related Articles

Never miss a story on Django.wiki

Subscribe for fresh tutorials, snippets, and updates.

By subscribing you agree to our Privacy Policy.