Sessions, Cookies, Messages & Signing

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Django 6.1 (6.1.1, released 2026-09-02; 6.1.2 is scheduled for 2026-10-06), Python 3.12-3.14 and PostgreSQL 15 or newer, checked on 2026-10-03; where Django 5.2 LTS (5.2.17) behaves differently the text says so. Every listing was run in a scratch project on Python 3.13.16 and Django 6.1.1 with SQLite: manage.py check, a suite of 57 tests (manage.py test), a curl session against runserver and the shell snippets all passed, and the output shown is the real output. Nothing on this page needs PostgreSQL or Redis to run; the cache-based session backends are shown with the local-memory cache in the tests, and the text says where a production deployment would use Redis or Memcached instead.

HTTP is stateless: each request arrives without memory of the previous one. Django gives you four layered tools to bridge that gap. A cookie is a small name/value pair the browser sends back with every request. A signed value is a string that carries a cryptographic signature, so tampering is detected even when the value travels through an untrusted place such as a cookie or a URL. A session is a per-visitor dictionary that lives on the server (or, with one backend, inside a signed cookie) and is found again through a session id cookie. The messages framework is a one-shot notification queue ("flash messages") built on top of cookies or sessions. This page covers all four, in that order from the lowest layer up, and finishes with a working session-based cart for the Bookshelf catalogue that grows across this section. The settings file that switches these features on is covered on Settings & Configuration, the models in the examples on Models & Fields, and the queries used to read them on Making Queries.

In a hurry: what to use for what

I need to…​ Use Section

Remember something about this visitor on the server

request.session["key"] = value (JSON-friendly values only); default backend is the database.

Using request.session

Remember something small, without a server-side store

A signed cookie (set_signed_cookie/get_signed_cookie) or, if it is session data, the signed_cookies backend.

Signed cookies, Cookie-based sessions

Make sessions fast

cached_db (write-through cache); cache only with a persistent Memcached/Redis.

Session backends

Read or write the session from an async view

await request.session.aget(…​), aset(…​), and the other a-prefixed methods (Django 5.1).

Sessions in async views (Django 5.1)

Create links or tokens that expire and cannot be forged

django.core.signing: dumps/loads with a salt and max_age, or TimestampSigner.

Cryptographic signing

Show "Saved." once after a redirect

messages.success(request, "…​") and iterate messages in the template.

The messages framework

Rotate SECRET_KEY without logging everyone out

Move the old key to SECRET_KEY_FALLBACKS.

Rotating SECRET_KEY: SECRET_KEY_FALLBACKS

Build a cart without a database table

Store {book id: quantity} in the session and read prices from the database.

A session-based cart, corrected

The Bookshelf pieces used on this page

Every example belongs to one app, catalog, of the Bookshelf project. The models are deliberately small: an Author, a Book with a price and a stock level, and (later) one extra model for a custom session engine.

catalog/models.py
from django.contrib.sessions.base_session import AbstractBaseSession
from django.db import models


class Author(models.Model):
    name = models.CharField(max_length=200)

    def __str__(self):
        return self.name


class Book(models.Model):
    title = models.CharField(max_length=200)
    author = models.ForeignKey(Author, on_delete=models.PROTECT, related_name="books")
    price = models.DecimalField(max_digits=6, decimal_places=2)
    stock = models.PositiveIntegerField(default=0)
    is_published = models.BooleanField(default=True)

    class Meta:
        ordering = ["title"]

    def __str__(self):
        return self.title

Source: Django: Models

The settings that matter come from django-admin startproject; the only change on this page is that SECRET_KEY is not hard-coded. In the scratch project it is read from the environment and falls back to a random key for throwaway runs:

config/settings.py (excerpt)
import os

from django.core.management.utils import get_random_secret_key

SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY") or get_random_secret_key()

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",   # the Session model (db / cached_db backends)
    "django.contrib.messages",   # the messages framework
    "django.contrib.staticfiles",
    "catalog",
]

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",   # must come first ...
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",   # ... because this one needs it
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]

A random fallback key means every restart invalidates every session and signed value, which is fine for a scratch project and wrong for production; the key must be stable, secret and unique per environment (see Settings & Configuration for the full story).

Cookies

A cookie is created by a Set-Cookie response header and returned by the browser in a Cookie request header (RFC 6265). Django exposes the incoming ones as the dictionary request.COOKIES (string values only) and the outgoing ones through three methods of HttpResponse.

def theme(request):
    current = request.COOKIES.get("theme", "light")           # read: plain dict of strings
    response = JsonResponse({"theme": current})
    response.set_cookie(
        "theme",
        "dark",
        max_age=60 * 60 * 24 * 30,   # seconds, or a timedelta; None = until the browser closes
        samesite="Lax",              # "Strict", "Lax" or the string "None"
        secure=True,                 # only over HTTPS
        httponly=False,              # JavaScript may read a preference cookie; never for credentials
    )
    return response


def forget_theme(request):
    response = HttpResponse("ok")
    response.delete_cookie("theme")  # path/domain must match what set_cookie used
    return response

The full signature in 6.1 is set_cookie(key, value='', max_age=None, expires=None, path='/', domain=None, secure=False, httponly=False, samesite=None):

Parameter Meaning

max_age

Lifetime. An integer number of seconds, a timedelta (accepted since Django 4.1) or None for a browser-session cookie. If expires is not given it is computed from this.

expires

An absolute time: a datetime in UTC or a string in the cookie date format. When a datetime is passed, max_age is calculated from it.

path, domain

Scope. domain="example.com" makes the cookie readable by www.example.com, blog.example.com and so on; by default only the setting host sees it.

secure

Send only on https requests.

httponly

Hide the cookie from client-side JavaScript. Use it for anything that is a credential.

samesite

'Strict' or 'Lax' stops the browser sending the cookie on cross-origin requests; 'None' (a string) sends it everywhere. Defence in depth, not a replacement for CSRF protection.

delete_cookie(key, path='/', domain=None, samesite=None) fails silently when the key does not exist, and the path and domain must match the ones used when the cookie was set or the browser keeps it. A browser stores at least 4096 bytes per cookie according to RFC 6265 and many accept no more; Django does not raise when you exceed that, the browser silently drops the cookie. Plain cookies are readable and writable by the user: never trust their content for authorisation, and never put anything secret in them.

Cookies Django sets for you

Several features already use cookies, so a minimal Django site has a handful before you write one:

Cookie (default name) Set by Controlled by

sessionid

SessionMiddleware, only when the session has been created or modified.

SESSION_COOKIE_* settings (The settings, in one table).

csrftoken

CsrfViewMiddleware / the {% csrf_token %} tag, when a template asks for a token.

CSRF_COOKIE_* settings.

messages

The cookie-based message storages (Storage backends).

SESSION_COOKIE_DOMAIN, SESSION_COOKIE_SECURE, SESSION_COOKIE_HTTPONLY.

django_language

LocaleMiddleware / set_language view.

LANGUAGE_COOKIE_* settings.

Since Django 2.1 the session, messages and CSRF cookies all default to SameSite=Lax; that is why a form on another site cannot POST using your user’s session. Since Django 4.2, session cookies are treated as credentials and their values are replaced by stars in error reports, so a debug page no longer leaks them.

Signed cookies

When you do need a cookie the user must not be able to edit (a display name, a "has accepted terms" marker, a small piece of state) Django can sign it. set_signed_cookie() and HttpRequest.get_signed_cookie() wrap the signing API from Cryptographic signing and add the cookie name to the signature, so a value signed for one cookie cannot be replayed as another:

catalog/views.py (signed cookie)
from datetime import timedelta

from django.http import HttpResponse, JsonResponse


def welcome(request):
    name = request.get_signed_cookie(
        "display_name", default=None, salt="catalog.display-name", max_age=3600
    )
    response = JsonResponse({"display_name": name})
    if "name" in request.GET:
        response.set_signed_cookie(
            "display_name",
            request.GET["name"],
            salt="catalog.display-name",
            max_age=timedelta(hours=1),
            httponly=True,
            samesite="Lax",
        )
    return response


def forget(request):
    response = HttpResponse("forgotten")
    response.delete_cookie("display_name", samesite="Lax")
    return response

The behaviour, verified with curl against runserver:

$ curl -si -c jar.txt "http://127.0.0.1:18926/welcome/?name=Ana" | grep -i set-cookie
Set-Cookie: display_name=Ana:1xCmoe:CblyCzAWyf2H57LnH_Lwho66ETSaYpMBXsHMBy5P4II; expires=Sat, 03 Oct 2026 00:37:24 GMT; HttpOnly; Max-Age=3600; Path=/; SameSite=Lax
$ curl -s -b jar.txt http://127.0.0.1:18926/welcome/
{"display_name": "Ana"}
$ curl -s -b "display_name=Mallory:abc" http://127.0.0.1:18926/welcome/
{"display_name": null}

Notice that the value Ana is plainly visible: signed is not encrypted. The signature (1xCmoe is the base62 timestamp, the rest the HMAC) only guarantees that the value was produced by your site and has not been altered. Four details are worth knowing:

  • get_signed_cookie(key, default=RAISE_ERROR, salt='', max_age=None) raises KeyError if the cookie is missing and BadSignature (or its subclass SignatureExpired) if it was tampered with or is older than max_age seconds; passing a default suppresses both the missing-cookie error and the signature errors. max_age is checked against the timestamp inside the signature, not against the cookie’s Max-Age attribute, which the client controls.

  • salt puts the cookie into its own signature namespace, and you must pass the same salt when reading. The test suite asserts that reading with a different salt raises BadSignature.

  • Signed cookies are not revocable. A stolen, still valid cookie works until it expires; there is no server-side record to delete. Keep the max_age short and keep authorisation decisions out of them.

  • The salt derivation changed in Django 6.1. Until the security fix recorded in the 6.1 documentation as "changed in 5.2.15", two different (cookie name, salt) pairs that concatenate to the same string ("ab" + "c" and "a" + "bc") shared one signature namespace, so a cookie signed in one place could be replayed in the other. The cookie name and salt length are now part of the derived salt. Per the 6.1 documentation, older versions accept cookies signed the old way by default, and in 6.1 the transitional setting SIGNED_COOKIE_LEGACY_SALT_FALLBACK defaults to False and is deprecated, to be removed in Django 2028. Set it to True temporarily if you must accept cookies issued by an older deployment during a rollout. A test in the scratch project forged a cookie with the old derivation and confirmed it is rejected by default and accepted when the setting is True.

The shape of the cookie also depends on SIGNING_BACKEND, whose default is django.core.signing.TimestampSigner.

Cryptographic signing

Everything above rests on django.core.signing. It is useful by itself whenever data has to travel through a channel the user controls: "reset your password" links, hidden form fields, one-time download URLs, email confirmation tokens, the share link of a reading list.

Signer: sign and verify a string

>>> from django.core.signing import Signer, BadSignature
>>> signer = Signer()
>>> value = signer.sign("My string")
>>> value
'My string:6qWbEDv3EZfyZwEz1zb6cAIxgvYENsKX0xFxk4C-A9g'
>>> signer.unsign(value)
'My string'
>>> signer.unsign(signer.sign(2.5))      # non-strings are forced to str
'2.5'
>>> try:
...     signer.unsign(value + "m")
... except BadSignature as exc:
...     print("BadSignature:", exc)
BadSignature: Signature "6qWbEDv3EZfyZwEz1zb6cAIxgvYENsKX0xFxk4C-A9gm" does not match

The signature is appended after a separator (a colon by default) and is computed with HMAC from SECRET_KEY. The signature values above will differ on every project because the key differs. The constructor in 6.1 is keyword-only:

Signer(*, key=None, sep=':', salt=None, algorithm=None, fallback_keys=None)

  • key overrides SECRET_KEY for this signer.

  • sep must not be in the URL-safe base64 alphabet (letters, digits, hyphen, underscore).

  • algorithm is any hashlib algorithm and defaults to 'sha256'.

  • fallback_keys is a list of extra keys used only to verify (default SECRET_KEY_FALLBACKS, see Rotating SECRET_KEY: SECRET_KEY_FALLBACKS).

Positional arguments (Signer("key")) were deprecated in Django 4.2 and removed in 5.1; the scratch tests confirm a TypeError today, so older tutorials that call Signer("my-key") need the key= keyword. The default algorithm moved from SHA-1 to SHA-256 in Django 3.1; support for the old SHA-1 signatures was removed in 4.0.

The salt: one namespace per purpose

>>> Signer().sign("My string")
'My string:6qWbEDv3EZfyZwEz1zb6cAIxgvYENsKX0xFxk4C-A9g'
>>> Signer(salt="extra").sign("My string")
'My string:HnM9ss2LRRcApLAcECoK80VVJ_xnuDbtF1d4zwkvXWo'

Different salts give different signatures for the same text, so a token minted for one feature cannot be fed to another. The salt is not a secret; it is a namespace. Use a constant per purpose, such as "catalog.share-wishlist" or "accounts.email-confirm", and never reuse the default.

TimestampSigner: expiring values

TimestampSigner appends a signed timestamp, and unsign(value, max_age=…​) raises SignatureExpired (a subclass of BadSignature) when the value is too old. max_age is an integer number of seconds or a timedelta:

>>> from datetime import timedelta
>>> from django.core.signing import TimestampSigner, SignatureExpired
>>> ts = TimestampSigner()
>>> t = ts.sign("hello")
>>> t
'hello:1xCmoE:1dUrRBnqlumSA9OTiICKVeFwX5lfOK24SoojSj94OnI'
>>> try:                                   # two seconds later
...     ts.unsign(t, max_age=1)
... except SignatureExpired as exc:
...     print("SignatureExpired:", exc)
SignatureExpired: Signature age 2.465151071548462 > 1 seconds
>>> ts.unsign(t, max_age=timedelta(seconds=20))
'hello'

Signing lists and dictionaries: sign_object, dumps, loads

Signer.sign_object() and unsign_object() (added in Django 3.2) serialise a list, tuple or dictionary to JSON, base64-encode it and sign it. TimestampSigner has the same two methods and adds max_age. The module-level shortcuts signing.dumps() and signing.loads() are TimestampSigner(salt='django.core.signing').sign_object()/unsign_object() with sensible defaults:

>>> signer = Signer()
>>> o = signer.sign_object({"message": "Hello!"})
>>> o
'eyJtZXNzYWdlIjoiSGVsbG8hIn0:psTPcr1zqrhc8TdYPva_xbpU5gKleZyr7Wbhcbmv2q4'
>>> signer.unsign_object(o)
{'message': 'Hello!'}

>>> from django.core import signing
>>> d = signing.dumps({"books": [1, 2]}, salt="catalog.share-wishlist", compress=True)
>>> d
'eyJib29rcyI6WzEsMl19:1xCmoG:Uk4TxxdnW60N4e-rmrhT5MzbCNpZptu8RJMp4eGDe08'
>>> signing.loads(d, salt="catalog.share-wishlist", max_age=3600)
{'books': [1, 2]}
>>> signing.loads(signing.dumps(("a", "b", "c")))     # JSON has no tuples
['a', 'b', 'c']
>>> signing.loads(d)                                   # wrong salt
Traceback (most recent call last):
...
django.core.signing.BadSignature: Signature "Uk4TxxdnW60N4e-rmrhT5MzbCNpZptu8RJMp4eGDe08" does not match

dumps(obj, key=None, salt='django.core.signing', serializer=JSONSerializer, compress=False) returns a URL-safe string; compress=True only compresses when that makes the result shorter, which is why the small payload above looks unchanged. loads(string, key=None, salt=…​, serializer=JSONSerializer, max_age=None, fallback_keys=None) is the inverse and raises BadSignature on failure. Because JSON is the serialisation format, a stolen SECRET_KEY lets an attacker forge signed values but not run code, which is the difference from the pickle-based signing the old books describe. The serializer argument exists, so do not pass a pickle-based one.

The Bookshelf lets a reader share a reading list as a URL. No database table is needed: the list of book ids is the token.

catalog/views.py (signed share link)
from datetime import timedelta

from django.core import signing
from django.core.signing import BadSignature, SignatureExpired
from django.http import Http404, HttpResponse, JsonResponse

from catalog.models import Book

SHARE_SALT = "catalog.share-wishlist"


def make_share_token(book_ids):
    return signing.dumps({"books": list(book_ids)}, salt=SHARE_SALT, compress=True)


def shared_list(request, token):
    try:
        payload = signing.loads(token, salt=SHARE_SALT, max_age=timedelta(days=2))
    except SignatureExpired:
        return HttpResponse("This link has expired.", status=410)
    except BadSignature:
        raise Http404("Invalid link")
    books = Book.objects.filter(pk__in=payload["books"], is_published=True)
    return JsonResponse({"titles": [b.title for b in books]})

SignatureExpired must be caught before BadSignature because it is the subclass. The URL pattern is path("shared/<str:token>/", views.shared_list, name="shared-list"); a token is a str containing a colon, which the default str converter accepts. A test in the scratch project builds a token, fetches it (200, titles returned), appends a character to it (404) and patches the clock three days forward (410). The payload is readable by anyone holding the link, so put ids in it, not private data; and because the link grants access to whatever the ids point to, still filter on the server (here is_published=True).

Rotating SECRET_KEY: SECRET_KEY_FALLBACKS

Everything signed is tied to SECRET_KEY: sessions on the signed_cookies backend, password-reset tokens, signed cookies, dumps(). Changing the key logs everyone out and invalidates every outstanding link, unless you rotate it: since Django 4.1 the SECRET_KEY_FALLBACKS setting lists previous keys that are still accepted for verification (never for signing):

# settings.py after a rotation
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]                 # the new key signs everything from now on
SECRET_KEY_FALLBACKS = [os.environ["DJANGO_SECRET_KEY_OLD"]]  # the previous key still verifies

Procedure: set the new key, put the old value first in the fallback list, deploy, and remove the old value once the oldest thing signed with it (the longest SESSION_COOKIE_AGE, the longest token lifetime) has expired. Every fallback key adds a verification attempt to each check that does not match an earlier key, so do not accumulate them. A test verified the contract: a value signed with the old key verifies under the new SECRET_KEY, and a fresh signature is different from the old one. The fallback list is not honoured by every third-party signing library, only by Django’s own signing and the features built on it.

What changed recently in signing

  • 3.1: SHA-256 replaces SHA-1 as the default; the transitional DEFAULT_HASHING_ALGORITHM setting existed for the upgrade and was removed in 4.0.

  • 3.2: sign_object()/unsign_object().

  • 4.1: SECRET_KEY_FALLBACKS; set_cookie() accepts a timedelta for max_age.

  • 5.1: positional arguments to Signer/TimestampSigner removed.

  • 6.1: unambiguous salts for signed cookies, SIGNED_COOKIE_LEGACY_SALT_FALLBACK (default False, deprecated). The default algorithm of django.core.signing.base64_hmac() and django.utils.crypto.salted_hmac() is deprecated too: pass an explicit algorithm if you call them directly.

Sessions

The session framework stores arbitrary per-visitor data on the server and sends the browser only an identifier. It is implemented by SessionMiddleware, which loads the session lazily when the view first touches request.session and saves it in the response phase.

sequenceDiagram participant B as Browser participant SM as SessionMiddleware participant V as View participant ST as Session store B->>SM: GET /cart/ (Cookie: sessionid=abc) SM->>V: request.session (lazy, not loaded yet) V->>ST: first access loads "abc" ST-->>V: data as a dict V->>V: request.session["cart"] = ... V-->>SM: response SM->>ST: save if modified (or SESSION_SAVE_EVERY_REQUEST) SM-->>B: Set-Cookie: sessionid=abc (only if created or modified)

Three consequences follow from the diagram. The store is only touched if the view reads or writes the session. The cookie is only sent if the session was created or modified (or SESSION_SAVE_EVERY_REQUEST is on), so a visitor who never gets session data never gets a cookie; a test asserts that a view that does not touch the session returns no sessionid. And the session is not saved when the response status is 500.

Enabling sessions

django-admin startproject already enables everything: SessionMiddleware in MIDDLEWARE and django.contrib.sessions in INSTALLED_APPS (needed for the db and cached_db backends only). The order matters: AuthenticationMiddleware and MessageMiddleware need the session, so SessionMiddleware must come before them. A site that never needs sessions can remove both lines and save a little overhead, but django.contrib.auth and the admin need them.

Using request.session

request.session behaves like a dictionary. All of its mutable and read methods, and their async twins, are listed below (the base class is django.contrib.sessions.backends.base.SessionBase):

Sync Async (5.1) What it does

s[key], s[key] = v, del s[key], key in s

aget, aset, apop, ahas_key

Dictionary access. del raises KeyError for a missing key; get(key, default=None) and pop(key, default) do not.

get, update, setdefault, pop

aget, aupdate, asetdefault, apop

The usual dict helpers.

keys(), values(), items(), has_key()

akeys, avalues, aitems, ahas_key

Iteration helpers. The async versions are coroutines returning the same data.

bool(s) (6.1)

not applicable

True if the session has any key (the inverse of is_empty()).

clear()

not applicable

Remove all keys (does not delete the session record; see flush).

flush()

aflush

Delete the session data and the cookie. logout() calls it.

set_test_cookie(), test_cookie_worked(), delete_test_cookie()

aset_test_cookie, atest_cookie_worked, adelete_test_cookie

Probe whether the browser accepts cookies (Test cookies).

set_expiry(value), get_expiry_age(), get_expiry_date(), get_expire_at_browser_close()

aset_expiry, aget_expiry_age, aget_expiry_date, aget_expire_at_browser_close

Per-session lifetime (Expiry: browser-length and persistent sessions).

cycle_key()

acycle_key

New session key, same data. login() calls it against session fixation.

is_empty(), get_session_cookie_age(), clear_expired()

aclear_expired

Introspection; clear_expired() is what clearsessions runs.

A first example that is easy to get wrong is a "remember my choice" setting. The following is the shape of the idiom; the cart in A session-based cart, corrected uses the same two ideas (JSON-friendly values and an explicit assignment):

def last_seen_book(request, book_id):
    previous = request.session.get("last_seen_book")        # None the first time
    request.session["last_seen_book"] = book_id             # assignment marks the session modified
    return JsonResponse({"previous": previous, "now": book_id})

Rules of thumb from the reference

  • Use plain strings as keys. Keys beginning with an underscore are reserved by Django (_auth_user_id, _messages and others).

  • Never replace request.session with another object and never set attributes on it; use it like a dictionary.

  • Store only what the serializer can round-trip (below). Store references (ids), not model instances.

  • Session data is not a cache for computed results: it is loaded for every request that touches it and rewritten when it changes.

When the session is saved: the modified gotcha

Django saves the session only if it believes it changed, which means a key was assigned or deleted on the session object. Mutating a value that is itself mutable does not count:

request.session["foo"] = "bar"           # modified
del request.session["foo"]               # modified
request.session["foo"] = {}              # modified
request.session["foo"]["bar"] = "baz"    # NOT modified: this changes the inner dict, not the session

request.session.modified = True          # tell Django explicitly

The scratch project reproduces it with two views that change prefs["theme"] in place. The one that sets request.session.modified = True persists the change; the one that does not changes nothing, silently:

catalog/views.py (the modified flag)
def prefs(request):
    prefs = request.session.setdefault("prefs", {"theme": "light"})
    if "theme" in request.GET:
        prefs["theme"] = request.GET["theme"]
        request.session.modified = True      # in-place change of a nested value
    return JsonResponse(prefs)


def nested_no_flag(request):                  # the bug: nested change, no flag
    prefs = request.session.setdefault("prefs", {"theme": "light"})
    prefs["theme"] = request.GET.get("theme", "dark")
    return JsonResponse(prefs)

The test asserts that after GET /prefs-bad/?theme=dark the stored value is still "light", and that after GET /prefs/?theme=dark it is "dark". (A freshly created {"theme": "light"} is persisted by setdefault, which does assign.) Two settings change the policy: SESSION_SAVE_EVERY_REQUEST = True writes on every request and resends the cookie every time, which also slides the expiry forward (at the price of a write per request); the default False writes only when modified, and an empty session is never created.

What can go into a session

Session data is serialized with SESSION_SERIALIZER, whose only included value in 6.1 is django.contrib.sessions.serializers.JSONSerializer. JSON restricts you in two ways that surprise people coming from older tutorials:

  • Only JSON types. datetime and Decimal fail: session["price"] = Decimal("12.50") raises TypeError: Object of type Decimal is not JSON serializable at save time (a test asserts this). Convert (str(price), an epoch integer) or, better, store an id and read the real value from the database.

  • String keys only. JSON object keys are strings, so session[0] = "bar" becomes session["0"] after the next request, and session[0] raises KeyError (also asserted by a test). That is why the cart below uses str(book.pk) as the key.

PickleSerializer was deprecated in Django 4.1 and removed in 5.0 because a leaked secret key could turn the signed-cookie backend into remote code execution; the books' advice to switch to pickle to store arbitrary objects therefore no longer works. If you really need richer types, write a serializer class with dumps(self, obj) and loads(self, data) and point SESSION_SERIALIZER at it, and prefer converting the values yourself instead.

Expiry: browser-length and persistent sessions

SESSION_COOKIE_AGE (default two weeks, 1209600 seconds) is the lifetime. SESSION_EXPIRE_AT_BROWSER_CLOSE = True turns every session into a browser-length one (the cookie has no Max-Age); some browsers restore sessions on restart, which defeats this in practice. set_expiry(value) overrides the policy for one session:

Value Meaning

An integer

Expire after that many seconds of inactivity (reading is not activity; the clock restarts when the session is modified or, with SESSION_SAVE_EVERY_REQUEST, on each request).

A datetime or timedelta

Expire at that moment.

0

Expire when the browser closes.

None

Fall back to the global policy.

def remember_me(request):
    if request.POST.get("remember") == "on":
        request.session.set_expiry(60 * 60 * 24 * 90)   # 90 days
    else:
        request.session.set_expiry(0)                    # until the browser closes
    ...

A test sets each form and checks get_expiry_age() (300), get_expire_at_browser_close() (True after 0) and the return to 1209600 after None. get_expiry_age() and get_expiry_date() are mainly there for backends; to compute remaining lifetime yourself, add SESSION_COOKIE_AGE seconds to the last modification time.

A server cannot know if a browser accepted a cookie until the next request, so Django splits the check across two views:

catalog/views.py (test cookie)
def cookie_check(request):
    if request.method == "POST":
        if request.session.test_cookie_worked():
            request.session.delete_test_cookie()
            return HttpResponse("Cookies work.")
        return HttpResponse("Please enable cookies and try again.", status=400)
    request.session.set_test_cookie()
    return HttpResponse('<form method="post"><button>Go</button></form>')

(The form is shown without a CSRF token because the scratch test uses the test client, which skips CSRF checks by default; a real form needs {% csrf_token %}.) The test posts first and gets 400, then does the GET, then posts again and gets Cookies work.. Clean up with delete_test_cookie().

Sessions in async views (Django 5.1)

Before Django 5.1, request.session could not be used from an async def view without wrapping every call in sync_to_async(). Since 5.1, SessionBase and all built-in engines have an async API: the same method names prefixed with a (aget, aset, aupdate, apop, akeys, avalues, aitems, ahas_key, asetdefault, aflush, acycle_key, aset_expiry and friends). Assigning with [] has no async form, hence aset(). The session store has aexists, acreate, asave, adelete, aload and aclear_expired; engines without a native async implementation wrap the sync methods with sync_to_async().

catalog/views.py (async session view)
async def visits(request):
    count = await request.session.aget("visits", 0)
    count += 1
    await request.session.aset("visits", count)
    return JsonResponse({"visits": count})

Three curl requests against runserver (which serves async views) with one cookie jar returned {"visits": 1}, {"visits": 2}, {"visits": 3}, and the first response carried Set-Cookie: sessionid=…​; HttpOnly; Max-Age=1209600; Path=/; SameSite=Lax. The test client counterpart is AsyncClient, whose asession() coroutine (added in Django 5.0 together with alogin() and alogout()) returns the session:

from django.test import AsyncClient, TestCase


class AsyncSessionTests(TestCase):
    async def test_async_view_uses_async_session_api(self):
        client = AsyncClient()
        first = await client.get("/visits/")
        second = await client.get("/visits/")
        self.assertEqual(first.json(), {"visits": 1})
        self.assertEqual(second.json(), {"visits": 2})
        session = await client.asession()
        self.assertEqual(await session.aget("visits"), 2)
        self.assertEqual(list(await session.akeys()), ["visits"])

Note that akeys() is a coroutine returning the keys, not an async iterator: the first draft of that test used async for and failed with TypeError. The same project served the sync and the async views side by side with no special configuration. Under WSGI an async def view still runs, in a short-lived event loop, so the async API pays off mostly on ASGI deployments.

Using the session outside a view

Anything that has a session key can open the store directly. In your own code import the store from the configured engine instead of a fixed backend:

>>> from importlib import import_module
>>> from django.conf import settings
>>> SessionStore = import_module(settings.SESSION_ENGINE).SessionStore
>>> s = SessionStore()
>>> s["last_seen_book"] = 7
>>> s.create()
>>> len(s.session_key)
32
>>> SessionStore(session_key=s.session_key)["last_seen_book"]
7

create() makes a new session and loops until it finds an unused key; save() is for existing sessions and a save() on a brand-new one can, with a very small probability, collide with an existing key. With the db backend a session is an ordinary model, so you can query it (note get_decoded()):

>>> from django.contrib.sessions.models import Session
>>> row = Session.objects.get(pk=s.session_key)
>>> row.get_decoded()
{'last_seen_book': 7}
>>> row.session_data[:30]
'eyJsYXN0X3NlZW5fYm9vayI6N30:1x'
>>> row.expire_date.year
2026

The stored session_data is a signed, base64-encoded JSON string, not the raw dictionary. Expired rows are invisible to users but stay in the table until you delete them; see Clearing expired sessions.

Session backends

SESSION_ENGINE chooses where session data lives. There are five included engines:

Engine (django.contrib.sessions.backends.<name>) Data lives in Strengths Weaknesses

db (default)

The django_session table (the Session model).

Durable, shared by all processes, no extra service, queryable.

A database read per request that touches the session and a write when it changes.

cached_db

Database and cache, written through to both.

Fast reads from the cache with durable fallback to the database; survives cache eviction.

Still writes to the database; needs a cache configured.

cache

Only the cache.

Fastest; no table to clean.

Eviction or a cache restart logs users out; only for Memcached or Redis; never local-memory in production.

file

Files in SESSION_FILE_PATH (default the system temporary directory).

No database.

Slow on busy servers, not shared across hosts without shared storage, files accumulate.

signed_cookies

The browser, inside a signed cookie.

No server-side storage at all.

Readable by the client, size limited to roughly 4 KB, no revocation or freshness guarantee.

All five were exercised in the scratch project with the same round trip (two requests to the async visits view, which must answer {"visits": 2}), using override_settings(SESSION_ENGINE=…​). The cache-based ones used the local-memory cache for the test only.

class EngineTests(TestCase):
    def roundtrip(self):
        client = Client()
        client.get("/visits/")
        return client, client.get("/visits/").json()

    def test_db(self):
        _, data = self.roundtrip()
        self.assertEqual(data, {"visits": 2})

    @override_settings(SESSION_ENGINE="django.contrib.sessions.backends.cached_db",
                       CACHES={"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}})
    def test_cached_db(self):
        _, data = self.roundtrip()
        self.assertEqual(data, {"visits": 2})
        self.assertEqual(Session.objects.count(), 1)       # and the table has the row

    @override_settings(SESSION_ENGINE="django.contrib.sessions.backends.signed_cookies")
    def test_signed_cookies(self):
        client, data = self.roundtrip()
        self.assertEqual(data, {"visits": 2})
        self.assertEqual(Session.objects.count(), 0)       # nothing server side

Database-backed sessions

The default. Add django.contrib.sessions to INSTALLED_APPS and run migrate to create the django_session table (session_key, session_data, expire_date). Every request that touches the session issues one SELECT, plus an UPDATE or INSERT if it changed. It is the right default until a profile shows sessions as a bottleneck.

Cache-based sessions: cached_db and cache

Cache-based sessions use the cache named by SESSION_CACHE_ALIAS (default 'default'). A production configuration with Redis (not run in the scratch project, which had no Redis server) looks like:

# production settings (Redis required)
CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.redis.RedisCache",   # Django 4.0+
        "LOCATION": "redis://127.0.0.1:6379/1",
    }
}
SESSION_ENGINE = "django.contrib.sessions.backends.cached_db"
SESSION_CACHE_ALIAS = "default"
  • cached_db is a write-through cache: a write goes to the database first and then to the cache; a failing cache write is logged on the sessions logger (Django 5.1) instead of failing the request. Reads come from the cache and fall back to the database when the entry was evicted. Use it when you need both speed and durability, and keep django.contrib.sessions installed.

  • cache stores sessions only in the cache. Eviction (cache full, restart) means lost sessions and logged-out users, so it is only reasonable with a cache configured for persistence. The documentation advises cached_db unless you are sure about that persistence. Do not use the local-memory backend for sessions: it is not shared between processes and loses data on restart, which breaks any multi-worker deployment.

  • Django’s built-in Redis cache backend arrived in 4.0 and PyMemcacheCache in 3.2 (MemcachedCache was removed in 4.1); the third-party packages the books recommend for Redis are no longer required for basic use.

File-based sessions

SESSION_ENGINE = "django.contrib.sessions.backends.file" writes one file per session below SESSION_FILE_PATH (the system temporary directory when unset). Make sure the web server user can read and write that directory. It is acceptable for a single small server and a poor fit for containers (the directory is not shared and vanishes with the container).

Cookie-based sessions

With SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies" the whole dictionary is serialised, signed with SECRET_KEY and stored in the sessionid cookie. No table, no cache, no cleanup, and it works on any number of servers without shared storage. The cost is a set of properties you must accept consciously:

  • Readable. The payload is base64-encoded JSON. A scratch test decodes the cookie of the visits view and reads {"visits": 2} straight out of it. Never store anything the client should not see.

  • Tamper-evident, not tamper-proof against loss. Altering any character makes the session invalid: the test replaces the last two characters of the cookie and the next request starts a new session ({"visits": 1}). A cookie larger than the browser limit (about 4096 bytes) is dropped by the browser and the session is lost, even though Django compresses it.

  • No freshness, no logout. The signature cannot tell the latest cookie from an older one, and logout() cannot revoke a copy somebody else holds: a stolen cookie works until SESSION_COOKIE_AGE runs out. Replay attacks are possible on anything that must be "used once".

  • Bigger cookies on every request, which costs bandwidth.

  • A leaked SECRET_KEY lets an attacker create valid sessions for any user. (With a pickle serializer it used to mean remote code execution, which is why pickle is gone.)

Keep SESSION_COOKIE_HTTPONLY at True. Use this backend for tiny, low-value state in a stateless deployment, not for authentication of a high-value application.

A custom database-backed engine

To query sessions by something other than the key (for example "log this user out everywhere"), extend the database engine with your own model. AbstractBaseSession and BaseSessionManager live in django.contrib.sessions.base_session, importable without django.contrib.sessions being installed. The abstract model supplies session_key (up to 40 characters; Django generates 32), session_data and expire_date, plus get_session_store_class() and get_decoded().

catalog/models.py (addition)
class AccountSession(AbstractBaseSession):
    account_id = models.IntegerField(null=True, db_index=True)

    @classmethod
    def get_session_store_class(cls):
        from catalog.session_backend import SessionStore

        return SessionStore
catalog/session_backend.py
from django.contrib.sessions.backends.db import SessionStore as DBStore


class SessionStore(DBStore):
    @classmethod
    def get_model_class(cls):
        from catalog.models import AccountSession

        return AccountSession

    def create_model_instance(self, data):
        obj = super().create_model_instance(data)
        try:
            account_id = int(data.get("_auth_user_id"))
        except (ValueError, TypeError):
            account_id = None
        obj.account_id = account_id
        return obj

Run makemigrations and migrate for the new model, then set SESSION_ENGINE = "catalog.session_backend" (the module that exports SessionStore). A test with that engine and client.force_login(user) confirmed that one AccountSession row with account_id == user.pk was created, so AccountSession.objects.filter(account_id=user.pk).delete() ends all of a user’s sessions. When customising cached_db instead, override cache_key_prefix so the keys do not clash with the stock engine.

Clearing expired sessions

Django never purges sessions automatically. With the db backend the django_session table grows, and with the file backend the directory fills up (the cache and cookie backends do not have this problem). Run the management command regularly, for example from a daily cron job or scheduled job:

$ python manage.py clearsessions

clearsessions calls clear_expired() on the engine (aclear_expired() is the async twin). It ran cleanly in the scratch project against SQLite. How it fits into scheduling is covered by Management Commands & the Shell.

The settings, in one table

Setting Default Notes

SESSION_ENGINE

…​backends.db

db, cached_db, cache, file, signed_cookies, or your own module.

SESSION_COOKIE_AGE

1209600 (two weeks)

Seconds. Overridable per session with set_expiry().

SESSION_COOKIE_NAME

'sessionid'

Must differ from other cookie names on the site.

SESSION_COOKIE_DOMAIN

None

"example.com" shares the cookie with subdomains; see Session security.

SESSION_COOKIE_PATH

'/'

Useful when several Django instances share a host under different paths.

SESSION_COOKIE_SECURE

False

Set to True in production. manage.py check --deploy reports security.W012 otherwise (reproduced in the scratch project).

SESSION_COOKIE_HTTPONLY

True

Keep it True; your code should not read the session cookie from JavaScript.

SESSION_COOKIE_SAMESITE

'Lax'

'Strict', 'Lax', 'None' (string, Django 3.1) or False to omit the flag.

SESSION_EXPIRE_AT_BROWSER_CLOSE

False

Browser-length sessions.

SESSION_SAVE_EVERY_REQUEST

False

Save and resend the cookie on every request.

SESSION_SERIALIZER

…​serializers.JSONSerializer

The only included serializer.

SESSION_CACHE_ALIAS

'default'

Cache used by cache and cached_db.

SESSION_FILE_PATH

None

Directory for the file engine (system temp dir when None).

Session security

  • Fixation. An attacker who can plant a known session id in your browser can later use it. django.contrib.auth.login() calls cycle_key() so the key changes at login; if you build your own login flow, call request.session.cycle_key() yourself. logout() calls flush(), which removes the data and the cookie.

  • Subdomains. A sibling subdomain may set cookies for the whole parent domain, which makes fixation possible if you do not control all subdomains. Likewise, a SESSION_COOKIE_DOMAIN of "example.com" sends the session cookie to every subdomain. Leave it None unless you need it and trust every subdomain.

  • No session ids in URLs. Django is cookie-only, by design: putting ids in URLs (as PHP can) exposes them through the Referer header.

  • Production flags. SESSION_COOKIE_SECURE = True, SESSION_COOKIE_HTTPONLY = True (default), SESSION_COOKIE_SAMESITE = "Lax" (default) and HTTPS everywhere; the CSRF cookie has its own settings.

  • Concurrent invalidation. Since 3.2, SessionMiddleware raises SessionInterrupted (a BadRequest subclass) instead of SuspiciousOperation when the session was destroyed by a concurrent request, so it surfaces as a 400 rather than a security log entry.

A session-based cart, corrected

Session carts are a classic first exercise: a shopper adds books before logging in, so the data cannot live in an order row yet. Tutorials and books in this area tend to share a handful of bugs, all of which the Bookshelf cart avoids on purpose.

Pitfall The cart below

Prices stored in the session next to the quantity (stale prices, a place to tamper with, and Decimal is not JSON)

The session stores only {"<book id>": quantity}; titles and prices are read from the database on every display.

Adding to the cart with a GET link

@require_POST plus {% csrf_token %}; a GET returns 405 (tested), and a POST without a token returns 403 (tested with enforce_csrf_checks=True).

Ids of deleted or unpublished books left in the session forever

Cart.prune() removes them each time the cart page renders (tested).

Quantity not bounded (negative, huge, above stock)

Clamped to 1..stock and to a per-line maximum of 10; a non-numeric value falls back to 1.

Integer keys, which JSON turns into strings

String keys everywhere.

Nested-dict edits that are never saved

The dictionary is reassigned to the session in _save(), which marks it modified.

catalog/cart.py
from decimal import Decimal

from catalog.models import Book

CART_SESSION_KEY = "cart"
MAX_PER_LINE = 10


class Cart:
    """A shopping cart kept in request.session.

    The session holds only {"<book id>": <quantity>}: JSON-friendly, small and
    free of prices. Titles and prices are always read from the database.
    """

    def __init__(self, request):
        self.session = request.session
        self._lines = self.session.get(CART_SESSION_KEY, {})

    def _save(self):
        # Assigning a new dict marks the session as modified.
        self.session[CART_SESSION_KEY] = self._lines

    def add(self, book, quantity=1, *, replace=False):
        key = str(book.pk)
        current = 0 if replace else self._lines.get(key, 0)
        new_quantity = min(current + quantity, MAX_PER_LINE, book.stock)
        if new_quantity <= 0:
            self._lines.pop(key, None)
        else:
            self._lines[key] = new_quantity
        self._save()

    def remove(self, book):
        if self._lines.pop(str(book.pk), None) is not None:
            self._save()

    def clear(self):
        self.session.pop(CART_SESSION_KEY, None)
        self._lines = {}

    def prune(self):
        """Drop ids whose book was deleted or unpublished since it was added."""
        live = set(
            Book.objects.filter(pk__in=[int(k) for k in self._lines], is_published=True)
            .values_list("pk", flat=True)
        )
        stale = [key for key in self._lines if int(key) not in live]
        for key in stale:
            del self._lines[key]
        if stale:
            self._save()

    def __len__(self):
        return sum(self._lines.values())

    def __iter__(self):
        books = Book.objects.filter(pk__in=[int(k) for k in self._lines], is_published=True)
        for book in books.select_related("author"):
            quantity = self._lines[str(book.pk)]
            yield {
                "book": book,
                "quantity": quantity,
                "line_total": book.price * quantity,
            }

    def total(self):
        return sum((line["line_total"] for line in self), Decimal("0.00"))

A few design notes. _lines is the same dictionary object that came out of the session; mutating it and then assigning it back is what makes Django save it. clear() is what an order-placement view calls after the order exists (inside the same transaction, see Transactions, Raw SQL & Multiple Databases). len(cart) is the number of items, not lines. A real shop would also lock stock when ordering and snapshot the price into the order line at that moment, because the price shown in the cart may legitimately change before checkout.

catalog/views.py (cart views)
from django.contrib import messages
from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.http import require_POST

from catalog.cart import Cart
from catalog.models import Book


def cart_detail(request):
    cart = Cart(request)
    cart.prune()
    return render(request, "catalog/cart.html", {"cart": cart, "total": cart.total()})


@require_POST
def cart_add(request, book_id):
    book = get_object_or_404(Book, pk=book_id, is_published=True)
    try:
        quantity = int(request.POST.get("quantity", "1"))
    except ValueError:
        quantity = 1
    quantity = max(quantity, 1)  # "add" never shrinks or deletes a line
    Cart(request).add(book, quantity)
    messages.success(request, f"Added {book.title} to your cart.")
    return redirect("cart-detail")


@require_POST
def cart_remove(request, book_id):
    book = get_object_or_404(Book, pk=book_id)
    Cart(request).remove(book)
    messages.info(request, f"Removed {book.title}.")
    return redirect("cart-detail")

The URLs are cart/, cart/add/<int:book_id>/ and cart/remove/<int:book_id>/. The template iterates the cart and uses a POST form with a CSRF token for removal; both the cart and the flash messages (next section) are rendered by one base template:

catalog/templates/catalog/cart.html
{% extends "catalog/base.html" %}
{% block content %}
<h1>Your cart ({{ cart|length }} items)</h1>
<table>
  {% for line in cart %}
  <tr>
    <td>{{ line.book.title }} by {{ line.book.author }}</td>
    <td>{{ line.quantity }} x {{ line.book.price }} = {{ line.line_total }}</td>
    <td>
      <form method="post" action="{% url 'cart-remove' line.book.pk %}">
        {% csrf_token %}<button>Remove</button>
      </form>
    </td>
  </tr>
  {% empty %}
  <tr><td>The cart is empty.</td></tr>
  {% endfor %}
</table>
<p>Total: {{ total }}</p>
{% endblock %}

Six of the cart tests, which verify the claims in the table above:

class CartTests(BookshelfData):
    def test_cart_stores_only_ids_and_quantities(self):
        self.client.post(reverse("cart-add", args=[self.book.pk]), {"quantity": "2"})
        self.assertEqual(self.client.session["cart"], {str(self.book.pk): 2})

    def test_prices_come_from_database_not_session(self):
        self.client.post(reverse("cart-add", args=[self.book.pk]), {"quantity": "2"})
        self.book.price = Decimal("20.00")
        self.book.save()
        response = self.client.get(reverse("cart-detail"))
        self.assertEqual(response.context["total"], Decimal("40.00"))

    def test_quantity_is_capped_by_stock_and_line_limit(self):
        self.client.post(reverse("cart-add", args=[self.book.pk]), {"quantity": "99"})
        self.assertEqual(self.client.session["cart"][str(self.book.pk)], 5)      # stock is 5
        self.client.post(reverse("cart-add", args=[self.other.pk]), {"quantity": "99"})
        self.assertEqual(self.client.session["cart"][str(self.other.pk)], 10)    # line limit

    def test_add_requires_post(self):
        response = self.client.get(reverse("cart-add", args=[self.book.pk]))
        self.assertEqual(response.status_code, 405)

    def test_csrf_is_enforced(self):
        client = Client(enforce_csrf_checks=True)
        response = client.post(reverse("cart-add", args=[self.book.pk]))
        self.assertEqual(response.status_code, 403)

    def test_deleted_and_unpublished_ids_are_pruned(self):
        self.client.post(reverse("cart-add", args=[self.book.pk]))
        self.client.post(reverse("cart-add", args=[self.other.pk]))
        Book.objects.filter(pk=self.book.pk).update(is_published=False)
        self.other.delete()
        response = self.client.get(reverse("cart-detail"))
        self.assertEqual(response.context["total"], Decimal("0.00"))
        self.assertEqual(self.client.session["cart"], {})

(The suite also asserts that removal clears the line and shows a flash message, and that the Cart class works on a bare SessionStore.) For a logged-in user the cart is usually merged into a database-backed order or basket at login; since login() calls cycle_key() the session id changes but the data, including the cart, is kept. The same page-level pattern with a database cart for authenticated users is a natural extension, and the session cart then only serves anonymous visitors.

The messages framework

The messages framework is for one-time notifications ("flash messages") that outlive exactly one redirect: a view adds a message and redirects; the next page renders and consumes it. It works for anonymous and authenticated users.

Enabling messages

Everything is on in a startproject project: django.contrib.messages in INSTALLED_APPS, MessageMiddleware after SessionMiddleware in MIDDLEWARE, and the context processor django.contrib.messages.context_processors.messages in the TEMPLATES option context_processors. The context processor provides two template variables: messages (the queue) and DEFAULT_MESSAGE_LEVELS (a mapping of level names to integers). The default storage uses sessions, which is why the session middleware must be listed first. Remove all three entries only if you really never need messages; the admin and the auth views use them.

Storage backends

MESSAGE_STORAGE picks the class (default FallbackStorage):

Class (django.contrib.messages.storage.<module>) Behaviour

fallback.FallbackStorage (default)

Tries CookieStorage first and falls back to SessionStorage for the messages that do not fit. Avoids writing the session whenever possible.

cookie.CookieStorage

Stores messages in a signed cookie. Old messages are dropped if the cookie data would exceed 2048 bytes.

session.SessionStorage

Stores messages in request.session under the key _messages. Requires django.contrib.sessions.

base.BaseStorage

The base class to subclass for a custom backend: implement _get and _store.

Tests with each setting showed what you would expect: with CookieStorage the response carries a messages cookie and the next request receives all five messages; with SessionStorage the messages key appears in the session; and with the default FallbackStorage, sixty messages made of random text (random, because the cookie is compressed and repeated text would shrink to fit) overflowed the cookie, so the response set a messages cookie _and wrote the rest into the session. The cookie storage uses SESSION_COOKIE_DOMAIN, SESSION_COOKIE_SECURE and SESSION_COOKIE_HTTPONLY for its cookie.

Levels and tags

Levels are integers, modelled after Python’s logging:

Constant Value Default tag Purpose

DEBUG

10

debug

Development messages, ignored (not stored) in production because MESSAGE_LEVEL defaults to INFO.

INFO

20

info

Informational.

SUCCESS

25

success

An action succeeded.

WARNING

30

warning

Something may go wrong soon.

ERROR

40

error

An action failed.

MESSAGE_LEVEL (default messages.INFO) is the minimum level recorded; lower ones are silently dropped. Tags are the level tag plus any extra tags, separated by spaces, and are meant to be CSS classes. Override or add tags with MESSAGE_TAGS, which extends the defaults, so you only list what you change:

from django.contrib.messages import constants as messages   # not `from django.contrib import messages` in settings

MESSAGE_LEVEL = messages.DEBUG                                # record everything while developing
MESSAGE_TAGS = {
    messages.INFO: "",                                        # no CSS class for plain info
    messages.ERROR: "danger",                                 # Bootstrap's name for an error
    50: "critical",                                           # a custom level
}

Import constants from django.contrib.messages in the settings file; importing the package’s functions there risks a circular import. A custom level is just another integer (take care not to reuse the built-in values); a reusable app should stay with the built-in levels. The scratch tests assert the tags produced by the flash view below: ["info", "success", "sticky warning", "error", ""] with the defaults and ["", "success", "sticky warning", "danger", "critical"] with the settings above; and that a DEBUG message appears only when MESSAGE_LEVEL is DEBUG.

Adding messages

from django.contrib import messages


def flash_levels(request):
    messages.debug(request, "debug message")                       # dropped unless MESSAGE_LEVEL <= DEBUG
    messages.info(request, "info message")
    messages.success(request, "success message")
    messages.warning(request, "warning message", extra_tags="sticky")
    messages.error(request, "error message")
    messages.add_message(request, 50, "critical message")          # custom level
    return redirect("messages-json")

add_message(request, level, message, extra_tags='', fail_silently=False) is the general form and debug, info, success, warning and error are shortcuts. extra_tags are placed before the level tag: the warning above has the tag string "sticky warning". All of them accept fail_silently=True, which suppresses the MessageFailure raised when the framework is disabled; a test shows messages.info(HttpRequest(), "boom") raising MessageFailure and the same call with fail_silently=True passing. Only that failure is hidden, not other errors.

The minimum level can also be changed within one request: messages.set_level(request, messages.WARNING) and back with messages.set_level(request, None); messages.get_level(request) reads the current value. A test runs a view that does this and confirms level_was == 30, level_now == 20, and that only the warning was recorded.

Displaying messages

In a template, iterate messages, which is lazy; iterating is what marks the messages as consumed:

catalog/templates/catalog/base.html
{% if messages %}
<ul class="messages">
  {% for message in messages %}
  <li{% if message.tags %} class="{{ message.tags }}"{% endif %}>
    {% if message.level == DEFAULT_MESSAGE_LEVELS.ERROR %}Important: {% endif %}{{ message }}
  </li>
  {% endfor %}
</ul>
{% endif %}

The template must be rendered with a RequestContext (the render() shortcut does that), otherwise the context processor does not run. Always loop over the messages even if you expect one, otherwise they stay in the store for the next request. A test confirms the output (<li class="error">, `Important: `) and that a second page view no longer contains the message.

Each item is a Message with message (the text), level, tags, extra_tags and level_tag. Outside templates use get_messages(request), which returns the configured storage instance, for example to return the messages as JSON:

catalog/views.py (messages as JSON)
from django.contrib import messages
from django.http import JsonResponse


def messages_json(request):
    storage = messages.get_messages(request)
    return JsonResponse({"messages": [{"level": m.level, "tags": m.tags, "text": str(m)} for m in storage]})

Fetching it through curl -L http://127.0.0.1:18926/flash/ (the flash view redirects here) printed:

{"messages": [{"level": 20, "tags": "info", "text": "info message"}, {"level": 25, "tags": "success", "text": "success message"}, {"level": 30, "tags": "sticky warning", "text": "warning message"}, {"level": 40, "tags": "error", "text": "error message"}, {"level": 50, "tags": "", "text": "critical message"}]}

Notice that debug message is missing, as the default MESSAGE_LEVEL is INFO.

Keeping messages for another request

Messages are marked for clearing when the storage is iterated and removed when the response is processed. To keep them available for the next request, set storage.used = False after iterating:

def keep_messages(request):
    storage = messages.get_messages(request)
    seen = [str(m) for m in storage]
    storage.used = False  # keep them for the next request
    return JsonResponse({"seen": seen})

A test calls this view twice and gets five messages both times, then reads them with the normal view and finds the queue empty.

In class-based views: SuccessMessageMixin

SuccessMessageMixin adds a success message to FormView-based views (including CreateView, UpdateView and, since Django 4.0, DeleteView, which became form-based). Put it before the view class and set success_message; the cleaned form data is available through %(field_name)s interpolation:

catalog/views.py (success message mixin)
from django.contrib.messages.views import SuccessMessageMixin
from django.views.generic.edit import CreateView

from catalog.models import Author


class AuthorCreate(SuccessMessageMixin, CreateView):
    model = Author
    fields = ["name"]
    template_name = "catalog/author_form.html"
    success_url = "/cart/"
    success_message = "%(name)s was created successfully"

For a ModelForm where the message needs values computed on the saved object, override get_success_message(cleaned_data) and use self.object. Note the explicit fields = ["name"]: never rely on "all" for a form that accepts user input.

Testing messages: MessagesTestMixin

Django 5.0 added MessagesTestMixin.assertMessages(response, expected_messages, ordered=True), which compares the messages attached to a response with a list of Message objects:

from django.contrib.messages import constants
from django.contrib.messages.storage.base import Message
from django.contrib.messages.test import MessagesTestMixin
from django.test import TestCase
from django.urls import reverse


class MessagesTests(MessagesTestMixin, TestCase):
    def test_assert_messages(self):
        response = self.client.post(reverse("author-create"), {"name": "Octavia Butler"})
        self.assertMessages(response, [Message(constants.SUCCESS, "Octavia Butler was created successfully")])

Before 5.0 the idiom was to follow the redirect and assertContains on the rendered HTML, or read list(get_messages(response.wsgi_request)); both still work, and assertMessages is shorter and does not depend on markup. Set ordered=False when the order is not significant.

Messages and async views

The messages API has no a-prefixed variants. Adding a message from an async def view works anyway: the storage collects messages in memory and MessageMiddleware stores them when the response passes through the (sync-capable) middleware chain. The scratch project adds messages.success(request, "added from an async view") in an async view and confirms the message is delivered on the next request through AsyncClient. Do not rely on this for anything that reads the session inside the view; use the async session API for that.

Caveats

  • Parallel requests. Messages ride on cookies or sessions, so when the same browser fires several requests at once, a message created by one may be delivered to another tab or, in some cases, not at all. It is normally not a problem for post/redirect/get flows.

  • Cookie size. The cookie backends cap data at about 2 KB; do not put long text in messages.

  • Rendering HTML in messages. Message text is escaped by the template like any variable. Use mark_safe or format_html only for fixed markup, never to wrap user input.

  • Admin. ModelAdmin.message_user() uses messages.INFO by default. Since Django 6.0 the DEBUG and INFO levels have their own icons and CSS in the admin instead of looking like SUCCESS; pass level=messages.SUCCESS to keep the old look.

Where the books are outdated

You may read Today

Session serialization is pickle, or switch to PickleSerializer to store objects

JSON is the only serializer included; PickleSerializer was deprecated in 4.1 and removed in 5.0.

SessionAuthenticationMiddleware must be added

Removed in Django 2.0; session authentication is always on and the middleware class no longer exists.

MIDDLEWARE_CLASSES lists SessionMiddleware and MessageMiddleware

MIDDLEWARE (2.0). The old setting is gone.

Signer("key") or SHA-1 signatures

Keyword arguments only (5.1); SHA-256 default since 3.1, SHA-1 compatibility removed in 4.0.

Changing SECRET_KEY logs everybody out

Rotate with SECRET_KEY_FALLBACKS (4.1).

Session code in async views must use sync_to_async

await request.session.aget(…​) and friends (5.1).

Cookie-based session and messages are "just signed"

The signed cookie salt derivation is unambiguous in 6.1 and SIGNED_COOKIE_LEGACY_SALT_FALLBACK defaults to False.

Add to cart through a link (GET)

State changes need POST plus CSRF protection; GET must be safe.

Messages tests follow a redirect and search HTML

MessagesTestMixin.assertMessages() (5.0).

Redis sessions need django-redis

Built-in Redis cache backend since 4.0, usable with cached_db.

What changed recently

Version Change

2.1

Session, messages and CSRF cookies default to SameSite=Lax.

3.1

SHA-256 default for signing and sessions; SESSION_COOKIE_SAMESITE accepts the string 'None'; session and message cookie encodings changed (old formats removed in 4.0).

3.2

Signer.sign_object()/unsign_object(); CookieStorage stores RFC 6265 compliant messages (old format removed in 4.1); SessionInterrupted.

4.0

DeleteView works with SuccessMessageMixin; Django’s Redis cache backend; pre-3.1 signatures and sessions no longer accepted.

4.1

SECRET_KEY_FALLBACKS; max_age accepts a timedelta in set_cookie(); PickleSerializer deprecated.

4.2

Session cookies hidden in error reports; positional Signer arguments deprecated.

5.0

PickleSerializer removed; MessagesTestMixin.assertMessages(); AsyncClient.asession().

5.1

Async session API on SessionBase and every engine; cached_db logs cache failures on the sessions logger; positional Signer arguments removed.

6.0

Admin gives DEBUG/INFO messages their own look.

6.1

bool(request.session); unambiguous signed-cookie salts (also in the 5.2.15 security release); SIGNED_COOKIE_LEGACY_SALT_FALLBACK is False by default and deprecated; explicit algorithm recommended for salted_hmac()/base64_hmac().

Pitfalls checklist

  • Editing a mutable value inside the session without request.session.modified = True (or reassigning the key).

  • Putting Decimal, datetime or model instances into the session; non-string keys; relying on pickle.

  • Using cache sessions on a cache that evicts, or the local-memory cache with several workers.

  • Forgetting clearsessions; the db/file stores grow without bound.

  • A hard-coded or random-per-restart SECRET_KEY (all sessions and signed values die with a restart) and no rotation plan.

  • Treating a signed cookie, a signed token or a cookie-backed session as secret: it is readable.

  • Leaving SESSION_COOKIE_SECURE/CSRF_COOKIE_SECURE off in production; manage.py check --deploy warns about both.

  • State-changing GET links (add to cart, logout); use POST and CSRF.

  • Cart and session data that reference ids which no longer exist.

  • Not iterating messages in the template, so the same message appears again on the next page.

  • Catching BadSignature before SignatureExpired; the more general class swallows the specific one.