Introduction to Django Signals

This article introduces Django signals and explains how they allow different parts of an application to respond to events such as model saves, deletions, many-to-many changes, requests, and user authentication. It covers receivers, senders, signal registration, common built-in signals, custom signals, transactions, testing, common mistakes, and when explicit function calls are a better choice.

Introduction to Django Signals

Django applications are made of components that perform different jobs.

A model saves data. A view processes a request. A user logs in. A many-to-many relationship changes. A database transaction completes.

Sometimes another part of the application needs to react when one of these events occurs.

For example:

  • create a profile when a user account is created
  • clear cached data after a model changes
  • record an audit entry after an object is deleted
  • send a notification after an order is placed
  • update related data when a relationship changes

Django signals provide a way to respond to these events without placing all the response logic directly inside the code that caused them.

Django describes signals as a notification system in which a sender informs one or more receivers that an action has occurred. They are most useful when multiple parts of an application may need to respond to the same event.

What Is a Signal?

A signal represents an event.

When the event occurs, the signal is sent. Functions connected to that signal are then called.

The main parts are:

Part Purpose
Signal Represents an event
Sender The object or class that sends the signal
Receiver A function that runs when the signal is sent
Connection Registers a receiver with a signal

The basic flow is:

text

1
2
3
4
5
6
7
An event occurs
      ↓
A signal is sent
      ↓
Connected receivers are called
      ↓
Each receiver performs its work

For example, Django sends the post_save signal after a model instance is saved.

A receiver can listen for that signal:

python

1
2
3
4
5
6
7
8
9
from django.db.models.signals import post_save
from django.dispatch import receiver

from .models import Article


@receiver(post_save, sender=Article)
def article_saved(sender, instance, created, **kwargs):
    print("An article was saved:", instance)

Whenever an Article is saved, Django calls article_saved().

Why Signals Exist

Without signals, one component must call every action that should happen afterward.

For example:

python

1
2
3
4
article.save()
clear_article_cache(article)
create_audit_entry(article)
notify_subscribers(article)

This code is explicit and easy to follow, but the component saving the article must know about every related action.

Signals allow those actions to register separately:

python

1
article.save()

The save operation emits a signal, and connected receivers respond.

This can be useful when:

  • an event has several independent listeners
  • reusable applications need to react to framework events
  • the sender should not depend directly on every receiver
  • behavior belongs outside the main operation

However, signals can also make program flow harder to trace because an ordinary operation such as save() may trigger code in another module. Django’s documentation warns that signals can create code that is difficult to understand, modify, and debug. When the sender and receiver are both controlled by the same project, an explicit function call is often clearer.

A Simple Model Signal

Suppose an application has an Article model:

python

1
2
3
4
5
6
7
8
9
from django.db import models


class Article(models.Model):
    title = models.CharField(max_length=200)
    is_published = models.BooleanField(default=False)

    def __str__(self):
        return self.title

A receiver can react whenever an article is saved:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
from django.db.models.signals import post_save
from django.dispatch import receiver

from .models import Article


@receiver(post_save, sender=Article)
def handle_article_save(sender, instance, created, **kwargs):
    if created:
        print("A new article was created:", instance.title)
    else:
        print("An article was updated:", instance.title)

The receiver receives information about the event through its arguments.

Receiver Arguments

A signal receiver usually accepts:

python

1
2
def receiver_function(sender, **kwargs):
    ...

The available keyword arguments depend on the signal.

For post_save, common arguments include:

Argument Purpose
sender Model class that sent the signal
instance Model instance that was saved
created True if a new record was created
raw Whether the model was saved in raw mode
using Database alias used
update_fields Fields passed through update_fields
kwargs Additional signal arguments

A typical receiver looks like this:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@receiver(post_save, sender=Article)
def article_saved(
    sender,
    instance,
    created,
    raw,
    using,
    update_fields,
    **kwargs,
):
    ...

Receivers commonly include **kwargs so they remain compatible if the signal provides additional arguments.

Connecting a Receiver

There are two common ways to connect a receiver to a signal:

  1. using the @receiver decorator
  2. calling the signal’s connect() method

Using the @receiver Decorator

The decorator approach is concise:

python

1
2
3
4
5
6
7
from django.db.models.signals import post_save
from django.dispatch import receiver


@receiver(post_save, sender=Article)
def article_saved(sender, instance, **kwargs):
    print("Saved:", instance)

The decorator connects article_saved() to post_save.

The sender=Article argument limits the receiver to saves involving the Article model.

Without a sender:

python

1
2
3
@receiver(post_save)
def any_model_saved(sender, instance, **kwargs):
    print("Saved model:", sender)

the receiver runs whenever any model sends post_save.

That is rarely desirable unless the receiver intentionally handles several model types.

Using connect()

A receiver can also be connected directly:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
from django.db.models.signals import post_save


def article_saved(sender, instance, **kwargs):
    print("Saved:", instance)


post_save.connect(
    article_saved,
    sender=Article,
)

Both approaches register the same kind of connection.

The decorator is often easier to read when the receiver is defined in the same module.

Direct connection can be useful when registration must be performed dynamically or when the receiver should remain independent from the decorator.

Where to Put Signal Receivers

A common project structure places receivers in a signals.py file:

text

1
2
3
4
5
6
7
8
articles/
├── __init__.py
├── admin.py
├── apps.py
├── models.py
├── signals.py
├── tests.py
└── views.py

Example signals.py:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
from django.db.models.signals import post_save
from django.dispatch import receiver

from .models import Article


@receiver(post_save, sender=Article)
def article_saved(sender, instance, created, **kwargs):
    if created:
        print("Created article:", instance.title)

Creating the file is not enough. Python must import it before the receiver can be registered.

A common place to perform that import is the app configuration.

Registering Signals in AppConfig.ready()

Open the app’s apps.py file:

python

1
2
3
4
5
6
7
8
9
from django.apps import AppConfig


class ArticlesConfig(AppConfig):
    default_auto_field = "django.db.models.BigAutoField"
    name = "articles"

    def ready(self):
        from . import signals

Importing signals causes the receiver decorators or connect() calls in that module to run.

The import is placed inside ready() to avoid importing application models before Django’s app registry is ready.

A more explicit import that avoids an unused-import warning is:

python

1
2
3
4
5
6
7
8
9
from django.apps import AppConfig


class ArticlesConfig(AppConfig):
    default_auto_field = "django.db.models.BigAutoField"
    name = "articles"

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

The app configuration must be loaded through INSTALLED_APPS.

Modern Django applications commonly use the app name:

python

1
2
3
4
INSTALLED_APPS = [
    # ...
    "articles",
]

Django can normally discover the app’s default configuration.

It may also be specified explicitly:

python

1
2
3
4
INSTALLED_APPS = [
    # ...
    "articles.apps.ArticlesConfig",
]

Common Model Signals

Django provides several signals for model activity. The most commonly used are:

Signal Sent when
pre_init A model instance begins initialization
post_init A model instance finishes initialization
pre_save Before a model instance is saved
post_save After a model instance is saved
pre_delete Before a model instance is deleted
post_delete After a model instance is deleted
m2m_changed A many-to-many relationship changes
class_prepared A model class is prepared

The exact arguments vary by signal. Django maintains a reference containing the arguments provided by each built-in signal.

pre_save

pre_save is sent before a model instance is saved.

python

1
2
3
4
5
6
7
8
9
from django.db.models.signals import pre_save
from django.dispatch import receiver

from .models import Article


@receiver(pre_save, sender=Article)
def before_article_save(sender, instance, **kwargs):
    instance.title = instance.title.strip()

This example removes surrounding whitespace before the article is saved.

Although this works, simple model-specific normalization may be clearer in:

  • a form
  • a model method
  • a service function
  • an overridden save() method

A signal is most useful when the behavior should remain separate from the model’s main implementation.

post_save

post_save is sent after a model instance is saved.

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
from django.db.models.signals import post_save
from django.dispatch import receiver

from .models import Article


@receiver(post_save, sender=Article)
def after_article_save(sender, instance, created, **kwargs):
    if created:
        print("Created:", instance)
    else:
        print("Updated:", instance)

The created argument distinguishes between insertion and update:

text

1
2
3
4
5
created=True
    A new database record was created.

created=False
    An existing database record was updated.

Creating Related Objects with post_save

A common introductory example creates a profile when a user account is created.

Profile model:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
from django.conf import settings
from django.db import models


class Profile(models.Model):
    user = models.OneToOneField(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
    )
    biography = models.TextField(blank=True)

    def __str__(self):
        return self.user.get_username()

Receiver:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
from django.conf import settings
from django.db.models.signals import post_save
from django.dispatch import receiver

from .models import Profile


@receiver(
    post_save,
    sender=settings.AUTH_USER_MODEL,
)
def create_user_profile(
    sender,
    instance,
    created,
    **kwargs,
):
    if created:
        Profile.objects.create(user=instance)

There is an important problem with this example: sender expects a model class, while settings.AUTH_USER_MODEL is a string such as "accounts.User".

Django signals support lazy sender references for model signals, so a string reference may be used:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@receiver(
    post_save,
    sender=settings.AUTH_USER_MODEL,
)
def create_user_profile(
    sender,
    instance,
    created,
    **kwargs,
):
    if created:
        Profile.objects.create(user=instance)

Another approach is to use the configured model class:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
from django.contrib.auth import get_user_model
from django.db.models.signals import post_save
from django.dispatch import receiver

from .models import Profile


User = get_user_model()


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

Be careful with this pattern. If profile creation is required for the account-creation workflow, calling a dedicated service function may make the dependency clearer and easier to test.

pre_delete

pre_delete is sent before an object is deleted.

python

1
2
3
4
5
6
7
8
9
from django.db.models.signals import pre_delete
from django.dispatch import receiver

from .models import Article


@receiver(pre_delete, sender=Article)
def before_article_delete(sender, instance, **kwargs):
    print("Deleting article:", instance.title)

At this point, the object still exists in the database.

This can be useful when information must be collected before deletion.

post_delete

post_delete is sent after an object is deleted.

python

1
2
3
4
5
6
7
8
9
from django.db.models.signals import post_delete
from django.dispatch import receiver

from .models import Article


@receiver(post_delete, sender=Article)
def after_article_delete(sender, instance, **kwargs):
    print("Deleted article:", instance.title)

The Python instance still exists in memory, but its database row has been removed.

A common use is deleting files associated with a model:

python

1
2
3
4
@receiver(post_delete, sender=Article)
def delete_article_image(sender, instance, **kwargs):
    if instance.image:
        instance.image.delete(save=False)

This should be implemented carefully. Shared files, storage errors, transactions, and rollback behavior can make automatic file deletion more complicated than the example suggests.

m2m_changed

m2m_changed is sent when a ManyToManyField relationship changes.

Models:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
from django.db import models


class Tag(models.Model):
    name = models.CharField(max_length=50)


class Article(models.Model):
    title = models.CharField(max_length=200)
    tags = models.ManyToManyField(Tag)

Receiver:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
from django.db.models.signals import m2m_changed
from django.dispatch import receiver

from .models import Article


@receiver(
    m2m_changed,
    sender=Article.tags.through,
)
def article_tags_changed(
    sender,
    instance,
    action,
    **kwargs,
):
    print("Action:", action)
    print("Article:", instance)

The sender is the intermediate model:

python

1
Article.tags.through

Common action values include:

Action Meaning
pre_add Before relationships are added
post_add After relationships are added
pre_remove Before relationships are removed
post_remove After relationships are removed
pre_clear Before all relationships are cleared
post_clear After all relationships are cleared

Example:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@receiver(
    m2m_changed,
    sender=Article.tags.through,
)
def article_tags_changed(
    sender,
    instance,
    action,
    pk_set,
    **kwargs,
):
    if action == "post_add":
        print("Added tag IDs:", pk_set)

Request and Response Signals

Django also provides signals related to request processing.

Common examples include:

Signal Sent when
request_started Django begins processing a request
request_finished Django finishes a response
got_request_exception An exception occurs during request handling

Example:

python

1
2
3
4
5
6
7
from django.core.signals import request_finished
from django.dispatch import receiver


@receiver(request_finished)
def request_completed(sender, **kwargs):
    print("Request finished")

Request signals should be used sparingly. Middleware usually provides a clearer way to implement logic that should run around every request. Django’s signal reference specifically recommends considering middleware before request and response signals because signals can make request flow harder to maintain.

Authentication Signals

Django’s authentication system sends signals for login-related events.

Common authentication signals include:

python

1
2
3
4
5
from django.contrib.auth.signals import (
    user_logged_in,
    user_logged_out,
    user_login_failed,
)

user_logged_in

Sent after a user logs in:

python

1
2
3
4
5
6
7
from django.contrib.auth.signals import user_logged_in
from django.dispatch import receiver


@receiver(user_logged_in)
def record_login(sender, request, user, **kwargs):
    print("User logged in:", user)

user_logged_out

Sent when a user logs out:

python

1
2
3
4
5
6
7
from django.contrib.auth.signals import user_logged_out
from django.dispatch import receiver


@receiver(user_logged_out)
def record_logout(sender, request, user, **kwargs):
    print("User logged out:", user)

user_login_failed

Sent when authentication fails:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
from django.contrib.auth.signals import user_login_failed
from django.dispatch import receiver


@receiver(user_login_failed)
def record_failed_login(
    sender,
    credentials,
    request,
    **kwargs,
):
    print("Login failed")

Avoid recording submitted passwords or other sensitive credentials. Authentication-related logging must be designed carefully to prevent sensitive information from appearing in logs.

The sender Argument

The sender identifies the source of the event.

For a model signal:

python

1
2
3
@receiver(post_save, sender=Article)
def article_saved(sender, instance, **kwargs):
    ...

the sender is the Article class.

This lets a receiver listen only for events from one model.

Without sender:

python

1
2
3
@receiver(post_save)
def every_model_saved(sender, instance, **kwargs):
    ...

the receiver listens for post_save from every model.

You can inspect the sender:

python

1
2
3
@receiver(post_save)
def model_saved(sender, instance, **kwargs):
    print("Model class:", sender)

Specifying a sender usually makes receivers more focused and avoids unnecessary calls.

Preventing Duplicate Registration

A receiver can sometimes be connected more than once.

This may happen because:

  • a signals module is imported repeatedly
  • app initialization runs more than expected in tests
  • a receiver is registered dynamically
  • development auto-reloading imports code again

A duplicate receiver may cause an action to run multiple times.

Use dispatch_uid to identify a connection:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
from django.db.models.signals import post_save


def article_saved(sender, instance, **kwargs):
    print("Saved:", instance)


post_save.connect(
    article_saved,
    sender=Article,
    dispatch_uid="articles.article_saved",
)

The unique identifier helps Django avoid registering the same logical receiver more than once.

The decorator also supports dispatch_uid:

python

1
2
3
4
5
6
7
@receiver(
    post_save,
    sender=Article,
    dispatch_uid="articles.article_saved",
)
def article_saved(sender, instance, **kwargs):
    ...

Weak References

Django stores signal receivers as weak references by default.

This means a locally defined receiver may be garbage-collected if nothing else keeps a strong reference to it.

A strong connection can be requested:

python

1
2
3
4
5
post_save.connect(
    article_saved,
    sender=Article,
    weak=False,
)

Module-level receiver functions normally remain available, so beginners rarely need to change weak.

It matters more when receivers are local functions, dynamically created functions, or callable objects with limited lifetimes.

Disconnecting a Receiver

Use disconnect() to remove a receiver:

python

1
2
3
4
post_save.disconnect(
    article_saved,
    sender=Article,
)

When dispatch_uid was used:

python

1
2
3
4
post_save.disconnect(
    sender=Article,
    dispatch_uid="articles.article_saved",
)

Disconnecting signals can be useful in:

  • tests
  • temporary maintenance code
  • dynamic plugin systems
  • situations where a receiver must be disabled temporarily

In ordinary application code, connections usually remain active for the lifetime of the process.

Custom Signals

Django allows applications to define their own signals.

python

1
2
3
4
from django.dispatch import Signal


order_completed = Signal()

Send the signal:

python

1
2
3
4
order_completed.send(
    sender=Order,
    order=order,
)

Connect a receiver:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
from django.dispatch import receiver

from .signals import order_completed


@receiver(order_completed)
def handle_completed_order(
    sender,
    order,
    **kwargs,
):
    print("Completed order:", order.id)

The sender can be any Python object or class representing the source of the event.

send() and send_robust()

Custom signals can be sent with:

python

1
signal.send(...)

or:

python

1
signal.send_robust(...)

With send(), an exception raised by a receiver interrupts signal dispatch and propagates to the caller.

With send_robust(), receiver exceptions are collected in the returned results rather than immediately stopping dispatch.

Example:

python

1
2
3
4
responses = order_completed.send_robust(
    sender=Order,
    order=order,
)

The result contains pairs of receivers and their returned values or exceptions.

Use this deliberately. Silently collecting exceptions may hide failures unless the results are inspected and logged.

When Custom Signals Are Appropriate

A custom signal may be useful when:

  • a reusable app exposes an event to outside applications
  • several independent components need to react
  • the sender should not import receivers
  • third-party code should be able to subscribe
  • listeners are optional extensions

For example, a reusable payment package may emit:

text

1
payment_completed

without knowing whether the host project will:

  • send an email
  • create an invoice
  • update analytics
  • award loyalty points

When an Explicit Function Call Is Better

Suppose an order must reserve inventory before it is considered complete.

A signal might hide that requirement:

python

1
order.save()

Somewhere else:

python

1
2
3
@receiver(post_save, sender=Order)
def reserve_inventory(...):
    ...

A developer reading the save operation cannot see that inventory reservation is a required part of the workflow.

An explicit service function is clearer:

python

1
2
3
4
5
6
7
8
def complete_order(order):
    reserve_inventory(order)
    charge_payment(order)

    order.status = Order.Status.COMPLETED
    order.save(update_fields=["status"])

    send_confirmation(order)

This makes the sequence and dependencies visible.

Use explicit calls when:

  • the action is required
  • execution order matters
  • failure must stop the operation
  • the sender and receiver are in the same project
  • the behavior is part of the core business process
  • developers must easily trace the workflow

Use signals when the reactions are optional, independent, or genuinely decoupled.

Signals and Database Transactions

A post_save receiver runs after the model’s save() method completes, but that does not always mean the surrounding database transaction has been committed.

For example:

python

1
2
3
4
5
from django.db import transaction


with transaction.atomic():
    article.save()

A post_save receiver runs inside the transaction.

If the transaction later rolls back, external work already performed by the receiver may not be rolled back.

This matters for actions such as:

  • sending email
  • publishing messages to a queue
  • calling external APIs
  • clearing shared caches
  • processing files

Use transaction.on_commit() when an action should happen only after a successful commit:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
from django.db import transaction
from django.db.models.signals import post_save
from django.dispatch import receiver

from .models import Article


@receiver(post_save, sender=Article)
def article_saved(sender, instance, created, **kwargs):
    if not created:
        return

    transaction.on_commit(
        lambda: notify_article_created(instance.pk)
    )

Passing the primary key instead of the model instance can be safer for delayed work because the callback can retrieve the committed database state.

Avoiding Recursive Signals

A receiver may accidentally trigger itself.

Example:

python

1
2
3
4
@receiver(post_save, sender=Article)
def article_saved(sender, instance, **kwargs):
    instance.is_processed = True
    instance.save()

The second save() sends post_save again, causing recursion.

Possible solutions include checking whether the update is needed:

python

1
2
3
4
5
6
7
8
@receiver(post_save, sender=Article)
def article_saved(sender, instance, **kwargs):
    if instance.is_processed:
        return

    Article.objects.filter(
        pk=instance.pk,
    ).update(is_processed=True)

A queryset update() does not call the model’s save() method and does not send pre_save or post_save.

This prevents recursion, but it also means other save-related behavior will not run.

An explicit service function may be clearer if the update is part of the normal workflow.

Signals and QuerySet Operations

Not every database operation sends model signals in the same way.

For example:

python

1
2
3
Article.objects.filter(
    is_published=False,
).update(is_published=True)

uses a direct SQL update.

It does not call each object’s save() method, so pre_save and post_save are not sent for each updated object.

Similarly, bulk operations may bypass normal per-instance behavior.

Do not build critical correctness rules around signals unless you understand every path that can modify the data.

Database constraints, model validation, service functions, and explicit workflows may provide stronger guarantees.

Keep Receivers Small

Signal receivers should usually remain short.

Avoid placing a large business workflow directly in the receiver:

python

1
2
3
4
5
@receiver(post_save, sender=Order)
def handle_order(sender, instance, **kwargs):
    # Hundreds of lines of payment, inventory,
    # email, analytics, and shipping logic.
    ...

Prefer delegating to a clearly named function:

python

1
2
3
4
@receiver(post_save, sender=Order)
def handle_order(sender, instance, created, **kwargs):
    if created:
        process_new_order(instance.pk)

This makes the receiver easy to inspect and the main logic easier to test directly.

Avoid Unnecessary Database Queries

Signals can run frequently.

A receiver connected to post_save may run every time an object is updated.

Avoid unnecessary queries:

python

1
2
3
@receiver(post_save, sender=Article)
def article_saved(sender, instance, **kwargs):
    article = Article.objects.get(pk=instance.pk)

The signal already provides the saved instance:

python

1
2
3
@receiver(post_save, sender=Article)
def article_saved(sender, instance, **kwargs):
    print(instance.title)

Query only when the receiver needs refreshed or related data that is not already available.

Testing Signal Receivers

Receivers can be tested by triggering the event:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import pytest

from articles.models import Article


@pytest.mark.django_db
def test_article_creation_creates_event():
    article = Article.objects.create(
        title="Signals",
    )

    # Assert the expected side effect.

The receiver’s delegated function can also be tested directly:

python

1
2
def test_handle_new_article():
    handle_new_article(article_id=1)

For focused unit tests, mocking can verify that the receiver delegates correctly:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
from unittest.mock import patch

import pytest

from articles.models import Article


@pytest.mark.django_db
@patch("articles.signals.handle_new_article")
def test_signal_handles_new_article(
    handle_new_article,
):
    article = Article.objects.create(
        title="Signals",
    )

    handle_new_article.assert_called_once_with(
        article.pk,
    )

Tests should confirm both:

  • the receiver is connected
  • the intended side effect occurs

Temporarily Disabling Signals in Tests

Signals can make tests harder to isolate.

A receiver can be disconnected temporarily:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
from django.db.models.signals import post_save

from articles.models import Article
from articles.signals import article_saved


post_save.disconnect(
    article_saved,
    sender=Article,
)

try:
    # Perform test setup without the receiver.
    ...
finally:
    post_save.connect(
        article_saved,
        sender=Article,
    )

Always reconnect the receiver, preferably with a fixture or context manager that guarantees cleanup.

Frequently disabling signals in tests may indicate that the application relies too heavily on hidden side effects.

Common Beginner Mistakes

Creating signals.py but Never Importing It

A receiver is not registered until its module is imported.

Use AppConfig.ready():

python

1
2
def ready(self):
    from . import signals

Forgetting **kwargs

Avoid:

python

1
2
3
@receiver(post_save, sender=Article)
def article_saved(sender, instance):
    ...

Prefer:

python

1
2
3
@receiver(post_save, sender=Article)
def article_saved(sender, instance, **kwargs):
    ...

The signal supplies additional keyword arguments.

Omitting the Sender

This receiver runs for every saved model:

python

1
2
3
@receiver(post_save)
def model_saved(sender, instance, **kwargs):
    ...

Use a sender when the receiver is model-specific:

python

1
2
3
@receiver(post_save, sender=Article)
def article_saved(sender, instance, **kwargs):
    ...

Performing Required Business Logic in a Hidden Receiver

If an order cannot be completed without reserving inventory, call the inventory logic explicitly.

Do not make essential workflow steps invisible.

Triggering Recursive Saves

This may call itself indefinitely:

python

1
2
3
@receiver(post_save, sender=Article)
def article_saved(sender, instance, **kwargs):
    instance.save()

Add a guard, use an appropriate queryset update, or redesign the workflow.

Assuming post_save Means Transaction Committed

A receiver may run before the surrounding transaction successfully commits.

Use transaction.on_commit() for external side effects that must happen only after commit.

Sending Expensive Work Synchronously

A receiver runs as part of the current execution flow.

Slow network calls or heavy processing can delay the request or command that triggered the signal.

Delegate expensive work to an appropriate task system when required.

Hiding Errors

A failed receiver can cause the original operation to fail when the signal uses normal send() behavior.

Handle expected errors deliberately, but do not silently ignore failures that indicate corrupted or incomplete behavior.

Using Signals Everywhere

Signals are not a replacement for ordinary function calls, service objects, model methods, or middleware.

Use them when decoupling provides a real benefit.

A Complete Example

Suppose a blog should record an event when a new article is created.

Models:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
from django.db import models


class Article(models.Model):
    title = models.CharField(max_length=200)
    content = models.TextField()

    def __str__(self):
        return self.title


class Activity(models.Model):
    message = models.CharField(max_length=250)
    created_at = models.DateTimeField(
        auto_now_add=True,
    )

    def __str__(self):
        return self.message

Signals:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
from django.db.models.signals import post_save
from django.dispatch import receiver

from .models import Activity, Article


@receiver(post_save, sender=Article)
def create_article_activity(
    sender,
    instance,
    created,
    **kwargs,
):
    if not created:
        return

    Activity.objects.create(
        message=(
            f'Article "{instance.title}" was created.'
        )
    )

App configuration:

python

1
2
3
4
5
6
7
8
9
from django.apps import AppConfig


class ArticlesConfig(AppConfig):
    default_auto_field = "django.db.models.BigAutoField"
    name = "articles"

    def ready(self):
        from . import signals

When this runs:

python

1
2
3
4
Article.objects.create(
    title="Introduction to Django Signals",
    content="...",
)

the receiver creates an Activity record.

The caller does not need to call the receiver directly.

A simple app can use this organization:

text

1
2
3
4
5
6
7
8
articles/
├── apps.py
├── models.py
├── services.py
├── signals.py
└── tests/
    ├── test_services.py
    └── test_signals.py

signals.py should contain small receivers:

python

1
2
3
4
5
6
7
8
9
@receiver(post_save, sender=Article)
def article_created(
    sender,
    instance,
    created,
    **kwargs,
):
    if created:
        record_article_creation(instance.pk)

services.py should contain the main behavior:

python

1
2
3
4
5
6
def record_article_creation(article_id):
    article = Article.objects.get(pk=article_id)

    Activity.objects.create(
        message=f'Created "{article.title}".'
    )

This keeps signal registration separate from the reusable application logic.

When to Use Signals

Signals are a reasonable choice when:

  • several independent receivers react to one event
  • a reusable app needs extension points
  • optional components respond to framework events
  • the sender should not import receiver modules
  • behavior is secondary to the main operation
  • built-in Django events provide the needed hook

Examples include:

  • audit logging
  • cache invalidation
  • analytics events
  • optional notifications
  • cleanup of related resources
  • integration hooks in reusable packages

When Not to Use Signals

Prefer explicit code when:

  • the action is required for correctness
  • the execution order matters
  • the caller needs the result
  • failures must be handled directly
  • the behavior is part of one business workflow
  • the sender and receiver are tightly related
  • a model method or service function is clearer
  • middleware better matches request-wide behavior

A useful rule is:

Use signals for notifications about an event, not to hide the main process that performs the event.

Mini Reference

text

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
Signal
    Represents an event

Sender
    Object or class that emits the signal

Receiver
    Function called when the signal is emitted

@receiver
    Decorator that connects a receiver

connect()
    Registers a receiver manually

disconnect()
    Removes a receiver

dispatch_uid
    Prevents duplicate registrations

Common model signals:

text

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
pre_save
    Before a model is saved

post_save
    After a model is saved

pre_delete
    Before a model is deleted

post_delete
    After a model is deleted

m2m_changed
    When a many-to-many relationship changes

Common authentication signals:

text

1
2
3
user_logged_in
user_logged_out
user_login_failed

Basic receiver:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
from django.db.models.signals import post_save
from django.dispatch import receiver


@receiver(post_save, sender=Article)
def article_saved(
    sender,
    instance,
    created,
    **kwargs,
):
    ...

Signal registration:

python

1
2
3
4
5
class ArticlesConfig(AppConfig):
    name = "articles"

    def ready(self):
        from . import signals

Transaction-safe external action:

python

1
2
3
transaction.on_commit(
    lambda: notify_article_created(instance.pk)
)

Django signals allow one part of an application to respond when an event occurs elsewhere.

The central ideas are:

  • a signal represents an event
  • a sender emits the signal
  • receivers listen for the signal
  • receivers can be connected with @receiver or connect()
  • Django provides model, request, authentication, and other built-in signals
  • receivers are commonly placed in signals.py
  • signal modules must be imported during app initialization
  • receivers should remain small and focused
  • required business workflows are usually clearer as explicit function calls
  • database transactions must be considered before performing external side effects
  • signals should reduce unwanted dependencies without hiding important application behavior

Signals are useful, but they should be introduced carefully. Start with explicit code. Use signals when the event genuinely needs independent listeners or when Django already provides a natural signal for the behavior you need.

Join the Newsletter

Practical insights on Django, backend systems, deployment, architecture, and real-world development — delivered without noise.

Get updates when new guides, learning paths, cheat sheets, and field notes are published.

No spam. Unsubscribe anytime.



There is no third-party involved so don't worry - we won't share your details with anyone.