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.
Table of Contents
- 1 Introduction to Django Signals
- 2 How Signals Work in Django
- 3 Overview of Built-in Signals
- 4 Built-in Model Lifecycle Signals
- 5 Built-in HTTP Request Signals
- 6 Built-in Database Connection Signals
- 7 Built-in Authentication Signals
- 8 Built-in Migration Signals
- 9 Other Framework Signals
- 10 Creating and Using Custom Signals
- 11 Real-World Use Cases
- 12 Best Practices for Django Signals
- 13 Common Pitfalls and Mistakes
- 14 When Not to Use Signals
- 15 Advanced Topics
- 16 Conclusion & Key Takeaways
- 17 Frequently Asked Questions
Article Details
- 2421 views
- Sep 25, 2025
- Category: Guides
- Topic: Django Fundamentals
- Tags #django #signals #python #best-practices #web-development
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.
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.
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_initpre_save,post_savepre_delete,post_deletem2m_changedclass_prepared
-
HTTP request lifecycle signals
request_startedrequest_finishedgot_request_exception
-
Authentication signals
user_logged_inuser_logged_outuser_login_failed
-
Migration signals
pre_migratepost_migrate
-
Database connection signals
connection_created
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)
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})
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;")
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')}")
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")
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
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.
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
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.pyorreceivers.pyinsideready()to avoid import loops and duplicate connections. - Keep handlers lightweight - no heavy I/O. Queue background jobs instead. Favor
transaction.on_commitfor 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=YourModelto avoid catching unrelated events. - Avoid recursion - when saving inside receivers, prefer
update_fieldsto 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.
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_uidand wire inapps.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_createand.update()do not callsave(), 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=YourModelto 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.
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()ordelete()if the logic belongs to the model itself. - Bulk operations – signals do not fire on
bulk_createor.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")
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")
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.pyto avoid duplicate connections. - Prefer
transaction.on_commitfor 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.