Django Intermediate #3: Signals and Middleware
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 #
| Signal | When sent | Common use |
|---|---|---|
pre_save | Right before model save() | Auto slug generation, normalization |
post_save | Right after save() | Cache invalidation, notifications |
pre_delete | Right before delete() | Clean up external resources |
post_delete | Right after delete() | Cache invalidation, logging |
m2m_changed | M2M relation changed | Permission/stat updates |
request_started / request_finished | Request start/end | Global logging |
user_logged_in / user_logged_out | Auth events | Update last-login time |
Registering a receiver — @receiver
#
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 (Postin the example above)instance— the saved model instancecreated—Truefor newly created,Falsefor 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.
from django.apps import AppConfig
class BlogConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "blog"
def ready(self):
from . import signals # registration triggerThe 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 #
import django.dispatch
post_published = django.dispatch.Signal()
# receiver
@receiver(post_published)
def on_published(sender, post, **kwargs):
print(f"published: {post.title}")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 responseYou send signals from a Signal() object via .send(sender, **kwargs).
pre_save — auto slug generation
#
@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.
# 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 #
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 #
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 #
| Place | Signal 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 model | ✅ | Same reason |
| Side effects within your own app | ❌ | Model methods/services are explicit |
| Pure persistence work inside a transaction | ✅ | Signals 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 #
browser
↓
SecurityMiddleware
↓
SessionMiddleware
↓
CommonMiddleware
↓
CsrfViewMiddleware
↓
AuthenticationMiddleware
↓
MessageMiddleware
↓
View ← response is generated here
↑
MessageMiddleware
↑
... (passes back through in reverse)
↑
browserInbound: top to bottom. Outbound: bottom to top. Each middleware wraps the next like layers of an onion.
Register in 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 #
| Middleware | What it does |
|---|---|
SecurityMiddleware | HTTPS redirect, HSTS, X-Content-Type-Options, etc. |
SessionMiddleware | Activates request.session (#5) |
CommonMiddleware | URL normalization (slash append), stat headers |
CsrfViewMiddleware | CSRF token validation |
AuthenticationMiddleware | Activates request.user (#4) |
MessageMiddleware | flash messages (#5) |
XFrameOptionsMiddleware | clickjacking 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 #
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 responseStructure:
__init__(self, get_response)— called once at app start.get_responsecalls 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
- Code before
MIDDLEWARE = [
...
"myapp.middleware.TimingMiddleware",
]Hook methods — process_view, process_exception, process_template_response
#
Define more methods if you need extra hooks.
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 responseWhere middleware commonly fits #
- Request ID issuance — set
X-Request-IDheader 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.
| Tool | Place |
|---|---|
| Decorator (or Mixin) | Cross-cutting concern applied only to specific views (e.g., @login_required) |
| Middleware | Global concern applied to every request (e.g., request ID, security headers) |
| Signal | Domain event hook for models/auth (for your own code, prefer explicit calls) |
Decision criteria:
- Specific views only → decorator / Mixin
- Every request → middleware
- Model post-save processing → method/service if same app, signal if external model
Small real example — last-seen time for active users #
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 responseUser.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.
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 inapps.py’sready() - 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), plusprocess_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.