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 |
|
|
Remember something small, without a server-side store |
A signed cookie ( |
|
Make sessions fast |
|
|
Read or write the session from an async view |
|
|
Create links or tokens that expire and cannot be forged |
|
|
Show "Saved." once after a redirect |
|
|
Rotate |
Move the old key to |
|
Build a cart without a database table |
Store |
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.
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:
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",
]
Source: Django: Settings - SECRET_KEY
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.
Reading, setting and deleting cookies
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 |
|---|---|
|
Lifetime. An integer number of seconds, a |
|
An absolute time: a |
|
Scope. |
|
Send only on |
|
Hide the cookie from client-side JavaScript. Use it for anything that is a credential. |
|
|
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 |
|---|---|---|
|
|
|
|
|
|
|
The cookie-based message storages (Storage backends). |
|
|
|
|
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:
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)raisesKeyErrorif the cookie is missing andBadSignature(or its subclassSignatureExpired) if it was tampered with or is older thanmax_ageseconds; passing adefaultsuppresses both the missing-cookie error and the signature errors.max_ageis checked against the timestamp inside the signature, not against the cookie’sMax-Ageattribute, which the client controls. -
saltputs 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 raisesBadSignature. -
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_ageshort 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 settingSIGNED_COOKIE_LEGACY_SALT_FALLBACKdefaults toFalseand is deprecated, to be removed in Django 2028. Set it toTruetemporarily 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 isTrue.
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)
-
keyoverridesSECRET_KEYfor this signer. -
sepmust not be in the URL-safe base64 alphabet (letters, digits, hyphen, underscore). -
algorithmis anyhashlibalgorithm and defaults to'sha256'. -
fallback_keysis a list of extra keys used only to verify (defaultSECRET_KEY_FALLBACKS, see RotatingSECRET_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.
Example: a signed, expiring share link
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.
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_ALGORITHMsetting existed for the upgrade and was removed in 4.0. -
3.2:
sign_object()/unsign_object(). -
4.1:
SECRET_KEY_FALLBACKS;set_cookie()accepts atimedeltaformax_age. -
5.1: positional arguments to
Signer/TimestampSignerremoved. -
6.1: unambiguous salts for signed cookies,
SIGNED_COOKIE_LEGACY_SALT_FALLBACK(defaultFalse, deprecated). The defaultalgorithmofdjango.core.signing.base64_hmac()anddjango.utils.crypto.salted_hmac()is deprecated too: pass an explicit algorithm if you call them directly.
Source: Django 6.1 release notes
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.
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 |
|---|---|---|
|
|
Dictionary access. |
|
|
The usual dict helpers. |
|
|
Iteration helpers. The async versions are coroutines returning the same data. |
|
not applicable |
|
|
not applicable |
Remove all keys (does not delete the session record; see |
|
|
Delete the session data and the cookie. |
|
|
Probe whether the browser accepts cookies (Test cookies). |
|
|
Per-session lifetime (Expiry: browser-length and persistent sessions). |
|
|
New session key, same data. |
|
|
Introspection; |
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})
Source: Django: Sessions - examples
Rules of thumb from the reference
-
Use plain strings as keys. Keys beginning with an underscore are reserved by Django (
_auth_user_id,_messagesand others). -
Never replace
request.sessionwith 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:
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.
datetimeandDecimalfail:session["price"] = Decimal("12.50")raisesTypeError: Object of type Decimal is not JSON serializableat 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"becomessession["0"]after the next request, andsession[0]raisesKeyError(also asserted by a test). That is why the cart below usesstr(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.
Source: Django: Sessions - serialization
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 |
A |
Expire at that moment. |
|
Expire when the browser closes. |
|
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
...
Source: Django: Sessions - set_expiry()
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.
Test cookies
A server cannot know if a browser accepted a cookie until the next request, so Django splits the check across two views:
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>')
Source: Django: Sessions - test cookies
(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().
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 |
|---|---|---|---|
|
The |
Durable, shared by all processes, no extra service, queryable. |
A database read per request that touches the session and a write when it changes. |
|
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. |
|
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. |
|
Files in |
No database. |
Slow on busy servers, not shared across hosts without shared storage, files accumulate. |
|
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_dbis a write-through cache: a write goes to the database first and then to the cache; a failing cache write is logged on thesessionslogger (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 keepdjango.contrib.sessionsinstalled. -
cachestores 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 advisescached_dbunless 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
PyMemcacheCachein 3.2 (MemcachedCachewas 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
visitsview 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 untilSESSION_COOKIE_AGEruns out. Replay attacks are possible on anything that must be "used once". -
Bigger cookies on every request, which costs bandwidth.
-
A leaked
SECRET_KEYlets 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().
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
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 |
|---|---|---|
|
|
|
|
|
Seconds. Overridable per session with |
|
|
Must differ from other cookie names on the site. |
|
|
|
|
|
Useful when several Django instances share a host under different paths. |
|
|
Set to |
|
|
Keep it |
|
|
|
|
|
Browser-length sessions. |
|
|
Save and resend the cookie on every request. |
|
|
The only included serializer. |
|
|
Cache used by |
|
|
Directory for the |
Source: Django: Settings - sessions
Session security
-
Fixation. An attacker who can plant a known session id in your browser can later use it.
django.contrib.auth.login()callscycle_key()so the key changes at login; if you build your own login flow, callrequest.session.cycle_key()yourself.logout()callsflush(), 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_DOMAINof"example.com"sends the session cookie to every subdomain. Leave itNoneunless 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
Refererheader. -
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,
SessionMiddlewareraisesSessionInterrupted(aBadRequestsubclass) instead ofSuspiciousOperationwhen the session was destroyed by a concurrent request, so it surfaces as a400rather 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 |
The session stores only |
Adding to the cart with a |
|
Ids of deleted or unpublished books left in the session forever |
|
Quantity not bounded (negative, huge, above stock) |
Clamped to |
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 |
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.
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:
{% 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 |
|---|---|
|
Tries |
|
Stores messages in a signed cookie. Old messages are dropped if the cookie data would exceed 2048 bytes. |
|
Stores messages in |
|
The base class to subclass for a custom backend: implement |
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 |
|---|---|---|---|
|
10 |
|
Development messages, ignored (not stored) in production because |
|
20 |
|
Informational. |
|
25 |
|
An action succeeded. |
|
30 |
|
Something may go wrong soon. |
|
40 |
|
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
}
Source: Django: Messages - message tags
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:
{% 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:
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:
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")])
Source: Django: Messages - testing
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_safeorformat_htmlonly for fixed markup, never to wrap user input. -
Admin.
ModelAdmin.message_user()usesmessages.INFOby default. Since Django 6.0 theDEBUGandINFOlevels have their own icons and CSS in the admin instead of looking likeSUCCESS; passlevel=messages.SUCCESSto keep the old look.
Where the books are outdated
| You may read | Today |
|---|---|
Session serialization is pickle, or switch to |
JSON is the only serializer included; |
|
Removed in Django 2.0; session authentication is always on and the middleware class no longer exists. |
|
|
|
Keyword arguments only (5.1); SHA-256 default since 3.1, SHA-1 compatibility removed in 4.0. |
Changing |
Rotate with |
Session code in async views must use |
|
Cookie-based session and messages are "just signed" |
The signed cookie salt derivation is unambiguous in 6.1 and |
Add to cart through a link ( |
State changes need |
Messages tests follow a redirect and search HTML |
|
Redis sessions need |
Built-in Redis cache backend since 4.0, usable with |
What changed recently
| Version | Change |
|---|---|
2.1 |
Session, messages and CSRF cookies default to |
3.1 |
SHA-256 default for signing and sessions; |
3.2 |
|
4.0 |
|
4.1 |
|
4.2 |
Session cookies hidden in error reports; positional |
5.0 |
|
5.1 |
Async session API on |
6.0 |
Admin gives |
6.1 |
|
Source: Django 6.1 release notes
Pitfalls checklist
-
Editing a mutable value inside the session without
request.session.modified = True(or reassigning the key). -
Putting
Decimal,datetimeor model instances into the session; non-string keys; relying on pickle. -
Using
cachesessions on a cache that evicts, or the local-memory cache with several workers. -
Forgetting
clearsessions; thedb/filestores 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_SECUREoff in production;manage.py check --deploywarns about both. -
State-changing
GETlinks (add to cart, logout); usePOSTand CSRF. -
Cart and session data that reference ids which no longer exist.
-
Not iterating
messagesin the template, so the same message appears again on the next page. -
Catching
BadSignaturebeforeSignatureExpired; the more general class swallows the specific one.