Middleware, signals and management commands
Run code around every request, react to model changes without hiding logic, and package operations as repeatable manage.py commands.
Writing middleware
Middleware is a chain of callables wrapped around the view. The request passes down the list in order; the response passes back up in reverse. That single rule explains almost every ordering bug: authentication must run before anything that reads request.user, and anything that touches the response headers must sit high enough to see responses from the layers above it.
# blog/middleware.py
import time
class RequestTimingMiddleware:
def __init__(self, get_response):
self.get_response = get_response # called once, at startup
def __call__(self, request):
started = time.perf_counter()
response = self.get_response(request) # do not wrap in try/except and swallow
response["X-Response-Time"] = f"{time.perf_counter() - started:.3f}s"
return response
class TenantMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
host = request.get_host().split(":")[0]
request.tenant = resolve_tenant(host) # attribute convention: no leading underscore
if request.tenant is None:
return HttpResponseNotFound("Unknown host") # short-circuit: skip the view
return self.get_response(request)
# config/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",
"blog.middleware.RequestTimingMiddleware",
]__init__runs once per process; never do per-request work there, and never keep request state onself— the same instance serves concurrent requests.- Returning a response from
__call__before callingget_responseshort-circuits the chain: later middleware never runs and the view is never reached. Useful for redirects, maintenance pages and tenant resolution. __call__may also be declaredasync defto take part in the async chain; Django adapts sync and async middleware at the boundary, but a blocking database call inside async middleware still blocks the event loop.- Order matters and is visible in
settings.MIDDLEWARE. Add your own after the security and session layers, and putCsrfViewMiddlewarebefore anything that reads the POST body for authentication.
Signals, and when they hurt
# blog/signals.py
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
from .models import Post
@receiver(post_save, sender=Post, dispatch_uid="blog.notify_published")
def notify_published(sender, instance, created, **kwargs):
if not created and instance.status == Post.Status.PUBLISHED:
enqueue_newsletter(instance.pk)
@receiver(post_delete, sender=Post, dispatch_uid="blog.cleanup_files")
def cleanup_files(sender, instance, **kwargs):
if instance.cover:
instance.cover.delete(save=False)
# blog/apps.py — import the module so the receivers are registered
from django.apps import AppConfig
class BlogConfig(AppConfig):
name = "blog"
def ready(self):
from . import signals # noqa: F401| Signal | Fires |
|---|---|
pre_save / post_save | Before/after Model.save(), including update_fields saves |
pre_delete / post_delete | Around instance and cascade deletion |
m2m_changed | Add/remove/clear on a many-to-many relation |
user_logged_in | After a successful login() |
request_started / request_finished | Around each request |
setting_changed | When override_settings() toggles a value — tests only |
- Signals fire per instance through the ORM.
queryset.update(),bulk_create(),bulk_update()and raw SQL bypass every model signal, so a workflow that depends on one is silently skipped by any bulk operation. dispatch_uidmakes registration idempotent, which matters because a module imported twice would otherwise connect the receiver twice and run the handler twice.- A signal that saves the same model inside its own
post_savehandler recurses. Guard withupdate_fields, a flag on the instance, or keep the write out of the handler.
Management commands
# blog/management/commands/expire_drafts.py
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
from datetime import timedelta
from blog.models import Post
class Command(BaseCommand):
help = "Delete or archive drafts older than N days."
def add_arguments(self, parser):
parser.add_argument("--days", type=int, default=90)
parser.add_argument("--archive", action="store_true",
help="Set status instead of deleting")
def handle(self, *args, **options):
cutoff = timezone.now() - timedelta(days=options["days"])
qs = Post.objects.filter(status=Post.Status.DRAFT, created_at__lt=cutoff)
count = qs.count()
if count == 0:
self.stdout.write("Nothing to do.")
return
qs.update(status=Post.Status.ARCHIVED) if options["archive"] else qs.delete()
self.stdout.write(self.style.SUCCESS(f"{count} drafts processed"))python manage.py expire_drafts --days 30 --archive
# cron: 17 4 * * * /srv/app/.venv/bin/python /srv/app/manage.py expire_drafts --days 30- The command class must sit in
<app>/management/commands/<name>.pywith__init__.pyfiles at each level; the file name becomes the command name. - Use
self.stdout.writerather thanprint, so output respects--verbosityand can be redirected; raiseCommandErrorfor a user-facing failure, which exits non-zero and prints a clean message. - In tests call
call_command("expire_drafts", days=1)and assert the resulting rows. The command is ordinary Python, so it needs no shell.
⚠️
Prefer an explicit call over a signal. If publishing a post must send a notification, a line in the service function that publishes it is visible in the traceback, easy to test and impossible to trigger accidentally. Signals are right for genuinely decoupled cross-app reactions — audit logging, cache invalidation, third-party plugins — and wrong as a general substitute for calling a function. When a codebase cannot answer "what happens when a Post is saved?", signals are usually the reason.
FAQ
Middleware or a decorator?
Middleware when the concern applies to every request regardless of view — security headers, timing, tenant resolution. A decorator when it applies to a chosen set of views, because it stays next to the view it affects and can take arguments.
How do I run a management command on a schedule?
For a single server, cron or a systemd timer calling
manage.py with the virtualenv's Python. For anything that needs retries, a queue, or visible run history, use Celery beat or a platform scheduler instead of adding more cron entries.Related
Settings, caching and query performance Deployment: gunicorn, static files and the security checklist
Last refreshed 2026-09-18.