skip to content

Sessions & Messages

Per-client state in Django: session store engines behind the session cookie, one-shot flash messages from contrib.messages, and django.core.signing. Interviewers probe engine trade-offs.

part ofDjangooverview, primer and where to startread it →
on this pageshow

explore

questions

14

In a Django view, why does appending to a list stored in request.session not persist to the next request?

level: juniorimportance: must knowfreq 52%

answer

  1. the session watches its own keys
  2. a flag decides the save
  3. nested objects change silently
  4. reassign or flip modified

basics

~20 s

Django saves request.session only when it is marked modified, and only assignments or deletions on the session itself set that flag. Mutating a nested list does not, so reassign the key or set request.session.modified = True.

solid answer

~40 s

`SessionBase` sets `modified` in `__setitem__`, `__delitem__`, `pop()` of an existing key, `setdefault()` when the key is missing, `clear()` and similar calls on the mapping itself. `SessionMiddleware.process_response` saves only if `modified` is true or `SESSION_SAVE_EVERY_REQUEST` is `True`, the session is not empty, and the status is below 500. `request.session['basket']['items'].append(sku)` changes an inner list, so the flag stays `False` and nothing is written — typically the first add works because `setdefault()` created the key, and later adds vanish. Fix it with `request.session['basket'] = basket` or `request.session.modified = True`. `SESSION_SAVE_EVERY_REQUEST = True` also works but writes the store on every request. Keep values JSON-serializable: the default `JSONSerializer` fails on `Decimal` or model instances.

code

python · 12 lines
python
from django.shortcuts import redirect
from django.views.decorators.http import require_POST


@require_POST
def add_to_basket(request, sku):
    basket = request.session.setdefault("basket", {"items": []})
    basket["items"].append(sku)
    # setdefault() marks the session modified only when it inserts the key,
    # so without the next line every add after the first is lost.
    request.session.modified = True
    return redirect("basket")

go deeper

for a junior

Remember the gotcha: assigning a session key is saved, changing something inside a stored dict or list is not unless you reassign it or set modified to True.

for a middle

Explain the modified flag, which SessionBase methods set it, and the three conditions SessionMiddleware checks before saving and sending the cookie.

for a senior

Weigh SESSION_SAVE_EVERY_REQUEST's write cost against sliding expiry, and design session payloads as small JSON of IDs rather than objects.

for a principal

Decide what belongs in the session at all; a basket that must survive devices or long gaps may deserve its own table instead of session storage.

## The bug in one picture A shopping basket kept in the session: ```python def add_to_basket(request, sku): basket = request.session.setdefault("basket", {"items": []}) basket["items"].append(sku) return redirect("basket") ``` The first add works. Every later add appears to succeed during the request and then disappears on the next page load. Nothing raises. ## Why: the modified flag `request.session` is a `SessionBase` object that wraps a plain dict loaded from the store. It keeps two flags: - **`accessed`** — set when the data is read; `SessionMiddleware` then adds `Vary: Cookie` to the response. - **`modified`** — set when the **session mapping itself** changes: `session[key] = value`, `del session[key]`, `pop()` of an existing key, `setdefault()` when the key is missing, `clear()`, `flush()`. At the end of the request `SessionMiddleware.process_response` saves the session only when: 1. `modified` is `True` **or** `SESSION_SAVE_EVERY_REQUEST` is `True`, 2. the session is not empty, and 3. the response status code is below 500. `basket["items"].append(sku)` changes a list **inside** a value. The session object never sees an assignment, so `modified` stays `False`. On the very first add, `setdefault()` had to insert the `"basket"` key, which set `modified`, so that request was saved. On later requests the key exists, `setdefault()` just returns it, and the append is never written back. ## The fixes | Fix | Effect | Cost | |---|---|---| | `request.session.modified = True` after the mutation | Saves this request | You must remember it at every nested mutation | | Reassign: `request.session["basket"] = basket` | `__setitem__` sets `modified` | Same discipline, clearer intent | | `SESSION_SAVE_EVERY_REQUEST = True` | Saves every non-empty session on every request below 500 | A store write and a `Set-Cookie` on every response | Reassigning or setting the flag in the view that mutates is the usual answer. `SESSION_SAVE_EVERY_REQUEST` is a global setting with a real write cost, sometimes chosen deliberately so the cookie's expiry slides forward on every request. ## The accessed flag matters too `accessed` does not trigger a save, but it changes the response: when a view merely reads `request.session`, the middleware adds `Cookie` to the `Vary` header, because the response now depends on who is asking. That is why touching the session in a template context processor on every page makes shared caches store those pages per cookie, which in practice defeats page caching. The async API added in Django 5.1 follows the same rules: `await request.session.aset(key, value)` marks the session modified, while mutating an object returned by `aget()` does not. ## Related traps - **Serializer limits.** `SESSION_SERIALIZER` defaults to `JSONSerializer`, so only JSON types survive. Putting a `Decimal` price, a `datetime` or a model instance in the basket raises `TypeError` when the middleware saves the session, which turns the response into a server error. Store IDs and strings, and rebuild objects on read. - **Errors do not save.** If the view returns a 5xx response, the middleware skips the save entirely, so a basket change made before an error is lost by design. - **Tuples come back as lists.** JSON has no tuple type. - **Checking for a session.** Since Django 6.1 `SessionBase` defines `__bool__`, returning the inverse of `is_empty()`: `if request.session:` is `False` only when there is no session key and no data. Before 6.1 the object had no `__bool__` or `__len__`, so the test was always `True`. ## A correct version ```python from django.shortcuts import redirect from django.views.decorators.http import require_POST @require_POST def add_to_basket(request, sku): basket = request.session.get("basket", {"items": []}) basket["items"].append(sku) request.session["basket"] = basket # __setitem__ marks the session modified return redirect("basket") ``` ## Summary - Django tracks changes to the session mapping, not to objects inside it. - Nested mutation needs a reassignment or `request.session.modified = True`. - The middleware saves only modified (or save-every-request) sessions, never on 5xx. - Session values must be JSON-serializable under the default serializer.

  • What happens if a Django view stores a Decimal price inside request.session under the default serializer?
    The default `SESSION_SERIALIZER` is `JSONSerializer`, and `json.dumps` cannot encode a `Decimal`. The view itself runs fine; the `TypeError` is raised when `SessionMiddleware` saves the session in `process_response`, turning the response into a server error. Store a string or integer cents and convert on read.
  • In Django 6.1, what does `if request.session:` evaluate to for a first-time visitor with no session cookie?
    `False`. Django 6.1 added `SessionBase.__bool__`, which returns `not is_empty()`, and a session with no key and no data is empty. It is `True` once the browser sends a session key or data is set. Before 6.1 there was no `__bool__` or `__len__`, so the expression was always `True`.
  • Does a Django view that changes the session and then returns a 500 response save the change?
    No. `SessionMiddleware.process_response` skips the save for any status code of 500 or above, so session changes made during a request that ends in a server error are discarded along with the response.

saying these in an interview costs you the question

  • Any change to data reachable from request.session is saved automatically
  • Django saves the session on every request by default
  • The session is written to the store immediately on assignment
  • Model instances can be stored in the session directly
  • The session is saved even when the view returns a 500
open as a page

In Django, how do you show a one-time 'Profile saved' notice on the page a user is redirected to?

level: juniorimportance: must knowfreq 55%

basics

~10 s

Call messages.success(request, 'Profile saved.') from django.contrib.messages before returning the redirect, and loop over messages in the base template; MessageMiddleware stores the notice across the redirect and iteration clears it.

open as a page

In Django's django.core.signing, what does Signer.sign() guarantee about a value, and why can anyone still read the signed result?

level: juniorimportance: must knowfreq 45%

basics

~10 s

Signer.sign() appends an HMAC-SHA256 signature derived from SECRET_KEY, so unsign() detects any change and raises BadSignature. It does not encrypt: the value, or its base64 JSON, travels in the clear.

open as a page

In Django, which session engines can SESSION_ENGINE select, which is the default, and how do they differ?

level: middleimportance: must knowfreq 55%

basics

~10 s

Django's SESSION_ENGINE selects db (the default, a django_session table), cache, cached_db (database with a write-through cache), file, or signed_cookies (data in a signed cookie); they differ in durability, speed, fleet-sharing and revocability.

open as a page

In Django's messages framework, what exactly consumes a message, and why can a notice survive redirects or appear on a later page?

level: middleimportance: should knowfreq 40%

basics

~20 s

In Django's messages framework only iterating the storage marks messages used; at the response, MessageMiddleware drops used messages and keeps unread ones, so a notice survives redirects and waits for the next page that loops over messages.

open as a page

In Django's messages framework, why does messages.debug() show nothing by default, and how do MESSAGE_LEVEL, MESSAGE_TAGS and extra_tags work?

level: middleimportance: should knowfreq 36%

basics

~10 s

Django records only messages at or above MESSAGE_LEVEL, which defaults to INFO (20), so DEBUG (10) messages are dropped when added. MESSAGE_TAGS overrides level-to-CSS tags, and extra_tags adds per-message tags to message.tags.

open as a page

In Django's django.core.signing, what does the salt argument do, and why is reusing one salt across features dangerous?

level: middleimportance: should knowfreq 35%

basics

~20 s

The salt is mixed into the key the HMAC uses, so a value only verifies under the salt it was signed with. Sharing one salt lets a token issued for one feature be replayed against another.

open as a page

A Django shop on several load-balanced servers sets SESSION_ENGINE to the cache engine without configuring CACHES, and baskets randomly empty; why, and what do you change?

level: seniorimportance: should knowfreq 36%

basics

~10 s

With no CACHES configured, Django's cache session engine uses the default LocMemCache, which lives inside one process, so requests landing on another worker see no session. Use cached_db, or a shared cache backend, instead.

open as a page

What can Django's signed_cookies session engine not do that the database engine can, and when is it still a reasonable choice?

level: seniorimportance: should knowfreq 34%

basics

~20 s

Django's signed_cookies engine keeps the whole session in a signed, unencrypted cookie, so it cannot revoke a session server-side, cannot hide data from the user and is capped by cookie size; it suits small, non-sensitive state.

open as a page

Why is Django's default MESSAGE_STORAGE FallbackStorage, and what goes wrong if you use CookieStorage or SessionStorage alone?

level: seniorimportance: should knowfreq 28%

basics

~20 s

Django's FallbackStorage keeps short notices in a signed messages cookie and spills overflow into the session, so the common case needs no session write. CookieStorage alone drops the oldest messages past 2048 bytes; SessionStorage alone saves the session for every notice.

open as a page

After a Django deploy replaced SECRET_KEY, every emailed signing.dumps() link fails with BadSignature; how does SECRET_KEY_FALLBACKS fix this, and how long must the old key stay?

level: seniorimportance: nice to knowfreq 22%

basics

~20 s

Django signs only with SECRET_KEY but verifies against SECRET_KEY, then each SECRET_KEY_FALLBACKS entry. Put the old key first in the fallbacks and keep it at least as long as the longest max_age of outstanding tokens.

open as a page