Skip to content

7. The Lifecycle of a Request

Between the arrival of a request and the dispatch of its response, [Django] goes through several steps, and the application can intervene at each one. We have already encountered several of these intervention points: the middleware components and their method [__call__] (examples 03, 12, 16, 23), their [process_exception] methods (examples 08 and 12) and [process_view] methods (example 21), and route converters (example 12). This chapter combines them into a single example, which displays a message in the console at each step so that the order can be observed:

    1. the [request_started] signal: the request has just arrived. A signal is a notification, emitted by [Django] at a specific moment, that application functions—the receivers—can receive. They cannot act on the request: they are simply notified;
    1. The [__call__] method of the middleware components, before the call to [get_response]: the middleware components are processed in the order specified by the [MIDDLEWARE] configuration. Routing has not yet taken place: a middleware component does not yet know which view will handle the request. It may also respond on its own, without invoking the rest of the chain: in that case, neither the subsequent middleware components nor the view are called;
    1. Routing: Once all middleware components have been processed, [Django] searches for the route that matches the requested path (example 02); the route parameter converters (example 12) are called at this point. If no route matches, a [Http404] (404) exception is thrown;
  • 3 and 4. The [process_view] method of the middleware components, in the order specified: the view has been selected but not yet called. A middleware component then knows the view and its parameters; it can let the process continue (return [None]), respond on behalf of the view, or throw an exception;
    1. the view, and the template it generates;
  • x. The [process_exception] middleware method, if the view has thrown an exception, in the reverse order of the configuration. The first one to provide a response stops the search. If none provides one, [Django] generates the error response itself: its detailed page in debug mode, or the application’s error page, provided by the views [handler400], [handler403], [handler404], and [handler500] (see below). An exception thrown elsewhere (in the [__call__] or [process_view] of a middleware component, or in routing) does not pass through [process_exception]: [Django] converts it into an error response upon exiting the relevant middleware;
  • 6 and 7. The [__call__] method of the middleware components, following the call to [get_response], in the reverse order of the configuration: the response is ready, and each middleware component can modify it (add a header, a cookie);
    1. The [request_finished] signal, sent after the response is sent to the browser. Receivers can no longer modify the response: they are used for processing tasks that can wait (such as closing database connections, which is what [Django] itself does), without making the user wait.

This is equivalent to the filters of [Java] and the interceptors of [Spring MVC], the action filters of [ASP.NET Core MVC], and the functions [before_request] / [after_request] and [Flask] functions, the middleware in [Laravel], and the event listeners in [Symfony]. Compared to these, the difference lies in the structure: a middleware component encapsulates the rest of the processing (it calls [get_response], and thus sees the request before and the response after, within the same code, like the Russian nesting dolls in Example 03), whereas an event listener reacts to an event, and you must listen for two events—one before and one after the action—to take action. The order of the middleware components, meanwhile, follows the list in the [MIDDLEWARE] configuration, rather than a numerical sequence: this is easier to read, but you must be familiar with the ordering constraints for the middleware components in [Django] (the session before authentication, for example; as specified in the [Django] documentation).

7.1. Example 24: The steps in processing a request

24-lifecycle/
├── calc/
│   ├── __init__.py
│   ├── apps.py
│   ├── converters.py
│   ├── errors.py
│   ├── logger.py
│   ├── middleware.py
│   ├── signals.py
│   ├── timer.py
│   └── views.py
├── config/
│   ├── settings.py
│   ├── urls.py
│   └── ...
├── templates/
│   ├── calc/
│   │   └── result.html
│   ├── error.html
│   └── layout.html
└── manage.py

The application has only one view, which calculates the square of an integer: [/square/7]. Depending on URL, it either succeeds or fails in various ways. The messages are written to the server console by a small function:

1
2
3
4
5
6
7
8
# calc/logger.py: a message in the console of the development server

import sys


def log(message):
    # stderr: the error output, displayed by the console like Django's messages
    print(message, file=sys.stderr)

By default, [print] writes to standard output; the [file=sys.stderr] parameter sends it to the error output, whereas [Django] writes its own messages: both will thus be displayed in the order in which they are generated. (A real application would use the [logging] module of [Python], which [Django] configures via its [LOGGING] setting: the case study does this.)

7.1.1. The configuration: config/settings.py

# config/settings.py: the configuration of example 24

from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent
DEBUG = True
SECRET_KEY = "django-insecure-1lvp12eazqudzob^%kot4e29)o)jyuph+fvt_dse*lus--6^s^"
# the accepted host names: essential as soon as DEBUG is False
ALLOWED_HOSTS = ["localhost", "127.0.0.1"]
LANGUAGE_CODE = "en"
TIME_ZONE = "Europe/Paris"
ROOT_URLCONF = "config.urls"
WSGI_APPLICATION = "config.wsgi.application"

INSTALLED_APPS = [
    "django.contrib.staticfiles",
    # the calc application: its configuration (calc/apps.py) connects our
    # signal receivers at startup
    "calc",
]

# the order of the middlewares: each one WRAPS the following ones
MIDDLEWARE = [
    "calc.middleware.Logger",
    "calc.middleware.Block",
    "calc.middleware.Timer",
    "calc.middleware.ErrorPage",
]

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [BASE_DIR / "templates"],
    },
]

STATIC_URL = "static/"
STATICFILES_DIRS = [
    BASE_DIR.parent / "common" / "static",
]
  • line 9: [ALLOWED_HOSTS] is populated so that the example can be deployed to production ([DEBUG = False]) and the application’s error pages can be viewed (see below);
  • lines 23–28: four middleware components, in the order in which the request passes through them.

7.1.2. Before and after everything else: the signals

# calc/signals.py: two Django SIGNALS, emitted at the start and at the end of each request

from django.core.signals import request_finished, request_started
from django.dispatch import receiver

from . import timer
from .logger import log


# @receiver: the function is called at each emission of the request_started signal,
# before all the middlewares
@receiver(request_started)
def start(sender, environ, **kwargs):
    log(f"--- {environ['REQUEST_METHOD']} {environ['PATH_INFO']}")
    log("0. signal request_started")


# request_finished: at the very end, AFTER the response is sent to the browser; we
# can no longer change the response
@receiver(request_finished)
def finish(sender, **kwargs):
    duration = timer.stop()
    # the measurement has not started if the view was not reached
    if duration is not None:
        log(f"8. signal request_finished (end, {duration} ms)")
  • line 3: the two signals from [Django] that frame each request;
  • lines 12–15: the [@receiver] decorator hooks the [start] function onto the [request_started] signal: it will be called every time the signal is emitted. A receiver receives the signal emitter ([sender]), followed by named parameters specific to each signal; [**kwargs] collects those it does not name, as required by the documentation for [Django] (a signal may receive new parameters in a future version). [request_started] provides [environ], the WSGI dictionary for the query (example 01): the [HttpRequest] object does not yet exist. The receiver displays the method and the path, followed by message #0;
  • lines 20–25: message No. 8, when everything is finished, with the duration measured by the timer (see below), if the measurement has started.

[Django] sends other signals: before and after saving a template from ORM ([pre_save], [post_save]), when a user logs in ([user_logged_in])... and the application can define its own. A receiver must be registered when the application starts, before the first request. The designated location for this is the application configuration:

# calc/apps.py: the CONFIGURATION of the calc application

from django.apps import AppConfig


class CalcConfig(AppConfig):
    name = "calc"

    # ready: Django calls it once, at startup, when all the applications
    # are loaded. This is where the signal receivers are connected
    def ready(self):
        from . import signals  # noqa: F401 (the import is enough to connect them)
  • lines 6–7: An application’s configuration is a class that inherits from [AppConfig], conventionally located in the application’s [apps.py] module, and which [Django] locates on its own. [name] is the name of the application package. This is also where you would give the application a human-readable name ([verbose_name]);
  • lines 11–12: [Django] calls the method [ready] once, at startup, when all applications are loaded. Importing the [signals] module there is sufficient: its [@receiver] decorators attach the receivers when they execute. The import is done within the method, rather than at the top of the file, because a module that uses the ORM templates cannot be imported until the applications have been loaded. The comment [# noqa: F401] instructs code-checking tools not to flag an “unused” import.

7.1.3. Before and After the View: The Logger Middleware

# calc/middleware.py: the middlewares of example 24, in the order of the MIDDLEWARE setting

from django.core.exceptions import BadRequest, PermissionDenied
from django.http import Http404
from django.shortcuts import render

from . import timer
from .errors import DomainError
from .logger import log


# the FIRST middleware: it sees each request go by before the others (and before
# the routing), and the response AFTER all the others
class Logger:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        log("1. Logger, before (before the routing)")
        response = self.get_response(request)
        log(f"7. Logger, after (code {response.status_code})")
        return response


# process_view: AFTER the routing: the middleware knows the chosen route
class Block:
    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):
        # resolver_match: the result of the routing (the view, the name of the route...)
        log(f"3. Block, process_view (route: {request.resolver_match.url_name})")
        if "block" in request.GET:
            # an exception: the rest (Timer, the view) is not executed
            raise PermissionDenied("access blocked")
        return None


# a middleware that only acts for the views marked by the @timed decorator:
# BEFORE the view (process_view), AFTER it; the end of the measurement is entrusted to the
# request_finished signal (see calc/signals.py)
class Timer:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        response = self.get_response(request)
        if getattr(request, "timed", False):
            log(f"6. Timer, after the view (code {response.status_code})")
        return response

    def process_view(self, request, view_func, view_args, view_kwargs):
        # getattr(object, name, default): the attribute of the object, or the default if it does not exist
        if getattr(view_func, "timed", False):
            log("4. Timer, process_view (before the view)")
            request.timed = True
            timer.start()
        return None


# process_exception: an exception left the view; this middleware replaces it
# with our error page
class ErrorPage:
    CODES = {BadRequest: 400, PermissionDenied: 403, Http404: 404}

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

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

    def process_exception(self, request, exception):
        for cls, code in self.CODES.items():
            if isinstance(exception, cls):
                log(f"x. ErrorPage, process_exception (HTTP {code})")
                break
        else:
            # the else of a for loop: executed if the loop was not interrupted by break
            if not isinstance(exception, DomainError):
                return None    # the others: Django's error page
            log(f"x. ErrorPage, process_exception (DomainError: {exception})")
            code = 500
        # the response replaces the exception; it then goes back through the middlewares
        # (Timer, then Logger), like an ordinary response
        return render(request, "error.html", {"code": code}, status=code)
  • Lines 14–22: the first middleware in the configuration: it processes the request before all others (message #1) and the response after all others (message #7), along with its code ([status_code]). It does not know the route, since routing has not yet taken place. It is used for processing that applies to all requests: logging, handling a proxy (the case study mentions this in relation to the clients’ address IP)...

7.1.4. Routing: calc/converters.py

# calc/converters.py: a path converter (see example 12): the segment
# turned into an integer, or into None if it is not an integer (it is then the
# view that decides)

from .logger import log


class IntegerConverter:
    # any segment, without a slash
    regex = r"[^/]+"

    def to_python(self, value):
        log(f'2. IntegerConverter, the routing (received value: "{value}")')
        try:
            return int(value)
        except ValueError:
            return None

    def to_url(self, value):
        return str(value)

A route converter (example 12), which writes message No. 2. It accepts any segment (the expression [[^/]+]: one or more characters that are not slashes) and converts it to a whole number, or to [None] if it is not one: unlike the [int] converter (example 02), which would return a 404 page for [/square/abc]; here, the view will determine the outcome. The file [config/urls.py] saves it as [integer]:

# config/urls.py: the main routing table

from django.urls import path, register_converter

from calc import errors, views
from calc.converters import IntegerConverter

# the "integer" converter (see calc/converters.py)
register_converter(IntegerConverter, "integer")

urlpatterns = [
    path("square/<integer:n>", views.square, name="square"),
]

# the views of the application's error pages: Django uses them for the errors
# 400, 403, 404 and 500 (except, in debug mode, for 400, 404 and 500, which it replaces
# with its detailed pages)
handler400 = errors.page_400
handler403 = errors.page_403
handler404 = errors.page_404
handler500 = errors.page_500
  • Lines 18–21: the error page views, described later.

7.1.5. After routing: the "Block" middleware

Let’s return to the [calc/middleware.py] file:

  • Lines 26–39: The second middleware only acts within its method ***[process\_view], after routing. [request.resolver_match] describes the result of the routing (Example 16): the route name ([url_name]***), the view, and its parameters. Message No. 3;
  • Lines 36–38: The parameter [?block] of URL causes a 403 error (Forbidden, access denied): The exception [PermissionDenied] interrupts processing; the subsequent [process_view]s and the view are not called.

7.1.6. Before the view: the Timer middleware and the @timed decorator

We want to time only certain views—those that request it. All we need to do is mark them with a decorator of our own design:

# calc/timer.py: one stopwatch per thread of execution, and the @timed decorator

import threading
import time

# threading.local: an object whose attributes are SPECIFIC to each thread of execution
# (thread); the server handles each request in its own thread
_local = threading.local()


def start():
    # perf_counter_ns: a precise clock, in nanoseconds
    _local.start = time.perf_counter_ns()


# the elapsed time in milliseconds, or None if the stopwatch has not started
def stop():
    start = getattr(_local, "start", None)
    _local.start = None
    if start is None:
        return None
    return round((time.perf_counter_ns() - start) / 1e6)


# a DECORATOR that does not change the view: it MARKS it, with an attribute that the
# Timer middleware can read
def timed(view):
    view.timed = True
    return view
  • lines 27–29: a decorator that does not replace the view; it adds an attribute, [timed], and returns it. In [Python], a function is an object to which attributes can be added. This is how [Django] itself marks certain views: [@csrf_exempt] (Example 18) adds the attribute [csrf_exempt], which [CsrfViewMiddleware] reads in its method [process_view];
  • Line 8: The timestamp must be passed from [process_view] to the signal [request_finished]. However, the server processes multiple requests simultaneously: a regular module variable would be shared by all of them. A [threading.local] object has attributes specific to each thread: each request, processed in its own thread, has its own start time;
  • lines 11–22: starting and stopping the timer. [time.perf_counter_ns()] is a precise clock, in nanoseconds, designed to measure durations. [getattr(object, name, default)] returns an object’s attribute, or the default value if it does not exist: the timer may not have started in this thread. The duration is returned in milliseconds.

The middleware that reads the mark, in the file [calc/middleware.py]:

  • lines 55–61: in [process_view], if the view contains the mark, message #4 is sent, and the timer starts. The mark is also stored in the request for later use. The decorator [@require_GET], applied on top of [@timed] (file [calc/views.py]), does not clear the mark: [functools.wraps] (example 08) copies the attributes of the function it wraps;
  • lines 49–53: in [__call__], after the view, message #6, with the response code, if the view was timed.

7.1.7. The view: calc/views.py

# calc/views.py: a timed view

from django.core.exceptions import BadRequest
from django.shortcuts import render
from django.views.decorators.http import require_GET

from .errors import DomainError
from .logger import log
from .timer import timed


# n: the route parameter, provided by our IntegerConverter converter;
# @timed: the Timer middleware measures the duration of this view
@require_GET
@timed
def square(request, n):
    log(f"5. view square({n})")
    if n is None:
        raise BadRequest("not an integer")    # 400 error: see ErrorPage
    if n == 13:
        raise DomainError("13 brings bad luck")    # an "unexpected" error
    return render(request, "calc/result.html", {"n": n, "square": n * n})
  • line 15: the view is marked with our decorator [@timed];
  • line 16: the parameter [n] is provided by our converter;
  • lines 18–19: a parameter that is not an integer: error 400 (Bad Request), caused by the exception [BadRequest] (example 07);
  • lines 20–21: the number 13 causes an “unexpected” error, a [DomainError] exception from the application;
  • line 22: the normal case. The [calc/result.html] template simply displays the result.

7.1.8. Error handling: the ErrorPage middleware and error views

The last middleware in the [calc/middleware.py] file replaces the error pages from [Django] with our own, for exceptions that escape the view:

  • line 67: a dictionary whose keys are exception classes and whose values are the corresponding HTTP codes;
  • lines 76–79: [isinstance] (example 03) checks whether the exception is of one of these classes (or a child class). If so, display message No. x, along with the code, and [break] terminates the loop;
  • Lines 80–85: The [else] of a [for] loop is executed if the loop has completed without a [break]: none of the three classes is suitable. A [DomainError] exception is an “unexpected” error, converted into a “custom” error page with status code 500 (Internal Server Error). The exception message is displayed in the console but not on the page: it may contain internal details that are not relevant to the user. Other exceptions are handled by [Django] ([return None]);
  • line 88: the response, which replaces the exception. It then passes through the preceding middleware components ([Timer], then [Logger]), just like a regular response.

This middleware only handles exceptions thrown by the view. For other cases (the [PermissionDenied] exception from the [Block] middleware, the 404 error from a URL middleware with no route), [Django] uses the application’s error views, declared in the [config/urls.py] file by the variables [handler400], [handler403], [handler404], and [handler500] (lines 18–21), and written to the file [calc/errors.py]:

# calc/errors.py: an exception of the application, and the views of the error pages

from django.shortcuts import render

from .logger import log


# an exception of the application: an "unexpected" error of a given type
class DomainError(Exception):
    pass


# the views of the error pages (see config/urls.py): exception is the exception that
# caused the error
def page_400(request, exception):
    return page(request, 400)


def page_403(request, exception):
    return page(request, 403)


def page_404(request, exception):
    return page(request, 404)


# the 500 page does not receive the exception
def page_500(request):
    return page(request, 500)


def page(request, code):
    log(f"x. view of the error page {code}")
    return render(request, "error.html", {"code": code}, status=code)
  • lines 9–10: the application exception: a class that inherits from [Exception], without adding anything to it ([pass] is the statement that does nothing: a block cannot be empty);
  • lines 15–29: The error views receive the request and the exception (except for view 500, which receives only the request);
  • lines 32–34: the same error page for all of them, with the error code (message #x).

There is one subtlety: in debug mode, [Django] retains its detailed pages for errors 400, 404, and 500, and uses these views only for error 403. This is intentional: during development, the detailed page is more useful. Outside of debug mode, all four views are used. Without these views, [Django] would use, outside of debug mode, its plain pages (example 03), or application templates named [400.html], [403.html], [404.html], and [500.html] if they exist: this is another, even simpler way to replace the error pages. The application’s error page:

{% extends "layout.html" %}

{# the error page; its model: the error code #}
{% block content %}
  <div class="alert alert-danger">
    <h1 class="h4">Error {{ code }}</h1>
    <p class="mb-0">
      {% if code == 400 %}Bad request.{% elif code == 403 %}Access denied.{% elif code == 404 %}Page not found.{% else %}Internal server error.{% endif %}
    </p>
  </div>
{% endblock %}
  • line 8: DTL does not have a dictionary defined in the template; the message is selected by a sequence of [{% elif %}].

7.1.9. Execution

The normal case, [/square/7]:

Image

The server console displays the steps, in order:

--- GET /square/7
0. signal request_started
1. Logger, before (before the routing)
2. IntegerConverter, the routing (received value: "7")
3. Block, process_view (route: square)
4. Timer, process_view (before the view)
5. view square(7)
6. Timer, after the view (code 200)
7. Logger, after (code 200)
[27/Sep/2026 19:38:41] "GET /square/7 HTTP/1.1" 200 359
8. signal request_finished (end, 5 ms)

The line in square brackets is the development server log, written after the response is sent, just before the signal [request_finished]. A blocked access, [/square/7?block]:

Image

--- GET /square/7
0. signal request_started
1. Logger, before (before the routing)
2. IntegerConverter, the routing (received value: "7")
3. Block, process_view (route: square)
x. view of the error page 403
Forbidden (Permission denied): /square/7
Traceback (most recent call last):
  ...
  File "/home/claude/django-sept-2026/examples/24-lifecycle/calc/middleware.py", line 38, in process_view
    raise PermissionDenied("access blocked")
django.core.exceptions.PermissionDenied: access blocked
7. Logger, after (code 403)
[27/Sep/2026 19:38:42] "GET /square/7?block HTTP/1.1" 403 443

The [Block] middleware raised an exception in [process_view]: neither the [process_view] from [Timer] nor the view were called. The exception did not pass through [process_exception] (it did not originate from the view): [Django] converted it into a response via the view [handler403], then wrote it to the log, along with its call stack (a [PermissionDenied] exception is a client error, which [Django] nevertheless logs in this way, at the “warning” level). The response passed through the second half of the [Logger] middleware. The [request_finished] signal was emitted but did not display anything, as the measurement had not started. A parameter that is not an integer, [/square/abc]:

Image

--- GET /square/abc
0. signal request_started
1. Logger, before (before the routing)
2. IntegerConverter, the routing (received value: "abc")
3. Block, process_view (route: square)
4. Timer, process_view (before the view)
5. view square(None)
x. ErrorPage, process_exception (HTTP 400)
6. Timer, after the view (code 400)
7. Logger, after (code 400)
Bad Request: /square/abc
[27/Sep/2026 19:38:42] "GET /square/abc HTTP/1.1" 400 441
8. signal request_finished (end, 1 ms)

This time, the view was called—[None]—and it was the one that raised the 400 error; [process_exception] rendered it as a page. [Django] still logged the 400 response in its log ([Bad Request: /square/abc]). An “unexpected” error, [/square/13]:

Image

--- GET /square/13
0. signal request_started
1. Logger, before (before the routing)
2. IntegerConverter, the routing (received value: "13")
3. Block, process_view (route: square)
4. Timer, process_view (before the view)
5. view square(13)
x. ErrorPage, process_exception (DomainError: 13 brings bad luck)
6. Timer, after the view (code 500)
7. Logger, after (code 500)
Internal Server Error: /square/13
[27/Sep/2026 19:38:42] "GET /square/13 HTTP/1.1" 500 451
8. signal request_finished (end, 1 ms)

The exception was handled by our middleware: the user sees the “custom” error page, not the detailed page, even though debug mode is enabled. Finally, a URL that does not correspond to any route, [/unknown]:

1
2
3
4
5
6
--- GET /unknown
0. signal request_started
1. Logger, before (before the routing)
7. Logger, after (code 404)
Not Found: /unknown
[27/Sep/2026 19:38:43] "GET /unknown HTTP/1.1" 404 2282

Routing failed: no [process_view], no view. In debug mode, the 404 page is the detail page for [Django] (example 02). Let’s switch the example to production mode ([DEBUG = False] in the [config/settings.py] file); we must then start the server using [python manage.py runserver --insecure] so that it continues to serve the static files (example 15):

curl http://localhost:8000/unknown
...
<body>
  <div class="container py-3">

  <div class="alert alert-danger">
    <h1 class="h4">Error 404</h1>
    <p class="mb-0">
      Page not found.
    </p>
  </div>

  </div>
</body>
...

The 404 page is now our own, generated by the view [handler404] (the console displays [x. view of the error page 404]).

Let’s summarize the key points. Middleware is the right place for checks common to many views (is the user logged in? Do they have permission to access this view?), and decorators are for those checks that apply to only a few views: authentication will rely on them, as we’ll see in the next chapter. [process_exception] and the error views ensure that the user never sees a technical page, regardless of the error: the case study takes advantage of this.