Django Intermediate #3: Signals and Middleware

7 min read

Django has two kinds of tools that let you slot code in outside the normal model/view flow.

  • Signals — an event system that reacts when “something happens”
  • Middleware — a pipeline that every request and response passes through

Both are powerful, but abuse leads to debugging hell. This post covers their usage along with when not to use them.

If #1 CBV and #2 ORM intermediate were “tools inside the normal flow”, this post is about tools that cross that flow.

Signals — an event system #

Django’s signals follow a sender → receiver pattern. One place sends “this happened”, and all registered receivers are called — synchronously, despite the asynchronous-looking model.

Built-in signals — the ones used most #

SignalWhen sentCommon use
pre_saveRight before model save()Auto slug generation, normalization
post_saveRight after save()Cache invalidation, notifications
pre_deleteRight before delete()Clean up external resources
post_deleteRight after delete()Cache invalidation, logging
m2m_changedM2M relation changedPermission/stat updates
request_started / request_finishedRequest start/endGlobal logging
user_logged_in / user_logged_outAuth eventsUpdate last-login time

Registering a receiver — @receiver #

blog/signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils.text import slugify

from .models import Post

@receiver(post_save, sender=Post)
def post_post_save(sender, instance, created, **kwargs):
    if created:
        # only for newly created posts
        send_notification_to_subscribers(instance)

The @receiver(signal, sender=Model) decorator binds a function to a signal. Handler signature:

  • sender — sender class (Post in the example above)
  • instance — the saved model instance
  • createdTrue for newly created, False for update
  • **kwargs — each signal has different extra arguments, so always accept **kwargs

Importing in apps.py’s ready() #

The signal module must be imported once at app load for the registrations to take effect. apps.py is the right place for this.

blog/apps.py
from django.apps import AppConfig

class BlogConfig(AppConfig):
    default_auto_field = "django.db.models.BigAutoField"
    name = "blog"

    def ready(self):
        from . import signals    # registration trigger

The reason to import inside ready() is to avoid circular imports and app-loading-order issues. Signals get registered after all models are loaded.

Custom signals #

blog/signals.py — custom signal
import django.dispatch

post_published = django.dispatch.Signal()

# receiver
@receiver(post_published)
def on_published(sender, post, **kwargs):
    print(f"published: {post.title}")
sending — inside a view or manager
from .signals import post_published

class PostUpdateView(UpdateView):
    def form_valid(self, form):
        response = super().form_valid(form)
        if self.object.published:
            post_published.send(sender=self.__class__, post=self.object)
        return response

You send signals from a Signal() object via .send(sender, **kwargs).

pre_save — auto slug generation #

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

It fills in the slug right before save. That said, for this kind of place, overriding the model’s save() is usually more appropriate. Explained in the next section.

Pitfalls of signals — when not to use #

Signals make it easy to create side effects that live too far away.

🚫 the start of debugging hell
# receivers scattered somewhere...
@receiver(post_save, sender=Order)
def send_email(...): ...

@receiver(post_save, sender=Order)
def update_stats(...): ...

@receiver(post_save, sender=Order)
def call_external_api(...): ...

You wrote one line Order.objects.create(...), but what happens where cannot be determined by looking at that one line alone. The handlers are scattered across many files.

Alternative 1 — model methods #

✅ explicit model method
class Post(models.Model):
    ...
    def save(self, *args, **kwargs):
        if not self.slug:
            self.slug = slugify(self.title)
        super().save(*args, **kwargs)

    def publish(self):
        self.published = True
        self.published_at = timezone.now()
        self.save()
        notify_subscribers(self)

When you see a single call to post.publish(), everything that happens is right there in that method. Far easier to trace than a signal handler.

Alternative 2 — manager / service function #

✅ service function
def publish_post(post: Post) -> None:
    post.published = True
    post.published_at = timezone.now()
    post.save()
    notify_subscribers(post)
    invalidate_homepage_cache()

This concentrates the domain logic in an explicit function. Easy to test, with a clear call path.

When signals really fit #

PlaceSignal OK?Why
Hook into a model sent by another app (Django built-in User, etc.)You can’t modify that model
Hook into an external library’s modelSame reason
Side effects within your own appModel methods/services are explicit
Pure persistence work inside a transactionSignals guarantee consistency

Principle: if you can change your own code directly, an explicit call beats a signal. Think of signals as a tool for hooking into models you don’t own.

Patterns that combine transactions with signals (transaction.on_commit, post_save + atomic) are detailed in Advanced #5.

Middleware — the request/response pipeline #

Middleware is a pipeline that every request and response passes through. Global concerns like auth, sessions, CSRF live here.

Behavior model #

request flow
browser
SecurityMiddleware
SessionMiddleware
CommonMiddleware
CsrfViewMiddleware
AuthenticationMiddleware
MessageMiddleware
View                    ← response is generated here
MessageMiddleware
... (passes back through in reverse)
browser

Inbound: top to bottom. Outbound: bottom to top. Each middleware wraps the next like layers of an onion.

Register in settings.py #

settings.py
MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]

Order matters. For example, AuthenticationMiddleware must come after SessionMiddleware (it reads the session to authenticate the user).

Built-in core middleware #

MiddlewareWhat it does
SecurityMiddlewareHTTPS redirect, HSTS, X-Content-Type-Options, etc.
SessionMiddlewareActivates request.session (#5)
CommonMiddlewareURL normalization (slash append), stat headers
CsrfViewMiddlewareCSRF token validation
AuthenticationMiddlewareActivates request.user (#4)
MessageMiddlewareflash messages (#5)
XFrameOptionsMiddlewareclickjacking defense (X-Frame-Options)

The default answer is to leave the built-ins enabled. Many of them are directly tied to security.

Writing middleware — class form #

myapp/middleware.py
import time
import logging

logger = logging.getLogger(__name__)

class TimingMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        start = time.perf_counter()
        response = self.get_response(request)
        elapsed = (time.perf_counter() - start) * 1000
        response["X-Render-Time"] = f"{elapsed:.1f}ms"
        logger.info(f"{request.method} {request.path} {elapsed:.1f}ms")
        return response

Structure:

  • __init__(self, get_response) — called once at app start. get_response calls the next middleware (or view)
  • __call__(self, request) — called per request
    • Code before self.get_response(request)request going in
    • Code after self.get_response(request)response going out
add to settings.py
MIDDLEWARE = [
    ...
    "myapp.middleware.TimingMiddleware",
]

Hook methods — process_view, process_exception, process_template_response #

Define more methods if you need extra hooks.

extended middleware
class AuditMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        return self.get_response(request)

    def process_view(self, request, view_func, view_args, view_kwargs):
        # Right before the view function is called. Return None to proceed normally
        request._view_func_name = view_func.__name__

    def process_exception(self, request, exception):
        # When an unhandled exception occurs in the view
        logger.error(f"unhandled exception: {exception}", exc_info=True)
        # Return None → delegate to other handlers / HttpResponse → use as response

    def process_template_response(self, request, response):
        # For TemplateResponse, right before render
        return response

Where middleware commonly fits #

  • Request ID issuance — set X-Request-ID header for distributed tracing
  • i18n / timezone — decide active language from Accept-Language
  • Maintenance mode — return 503 except for specific paths
  • Simple rate limit — cache + IP-based (use a separate tool for big traffic)
  • Common response headers — security headers, CORS, etc.

Middleware vs decorator vs signal — when to use which #

The three tools are easy to confuse.

ToolPlace
Decorator (or Mixin)Cross-cutting concern applied only to specific views (e.g., @login_required)
MiddlewareGlobal concern applied to every request (e.g., request ID, security headers)
SignalDomain event hook for models/auth (for your own code, prefer explicit calls)

Decision criteria:

  1. Specific views only → decorator / Mixin
  2. Every request → middleware
  3. Model post-save processing → method/service if same app, signal if external model

Small real example — last-seen time for active users #

myapp/middleware.py
from django.utils import timezone

class LastSeenMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        response = self.get_response(request)
        if request.user.is_authenticated:
            User = request.user.__class__
            User.objects.filter(pk=request.user.pk).update(
                last_seen_at=timezone.now()
            )
        return response

User.objects.filter(...).update(...) updates without re-saving the user object. (The pattern from #2.) To reduce per-request load, add throttling like “update only every 5 minutes”.

Async middleware briefly #

Since Django 4.0+, you can write middleware as async too. Full async views are covered in Advanced #1, but middleware can be written to support both sync and async.

supporting both sync and async
from asgiref.sync import iscoroutinefunction

class HybridMiddleware:
    sync_capable = True
    async_capable = True

    def __init__(self, get_response):
        self.get_response = get_response
        self.async_mode = iscoroutinefunction(get_response)

    def __call__(self, request):
        if self.async_mode:
            return self.__acall__(request)
        return self.get_response(request)

    async def __acall__(self, request):
        return await self.get_response(request)

Detailed patterns in Advanced #1.

Summary #

What we covered in this post:

  • Signals — sender/receiver event system
  • Built-in signals: pre_save, post_save, pre_delete, post_delete, m2m_changed, auth events
  • Register with @receiver(signal, sender=Model), import in apps.py’s ready()
  • Signal pitfalls — side effects too far away, hard debugging. For your own models, methods/services come first
  • Middleware — request/response pipeline, onion-layer flow
  • Built-in core (Security, Session, Csrf, Authentication, Message)
  • Middleware shape: __init__(get_response), __call__(request), plus process_view/process_exception
  • Roles of the three tools: specific view → decorator/Mixin, every request → middleware, domain event → signal (for your own code, explicit calls first)

In the next post (#4 Users/permissions), we stack custom user model, permission, group on top of the built-in auth from Basics #7. Including the decisions you have to make at project start.

X