In a Django view, why does appending to a list stored in request.session not persist to the next request?
answer
- the session watches its own keys
- a flag decides the save
- nested objects change silently
- reassign or flip modified
basics
~20 sDjango 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 linesfrom 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
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.
Explain the modified flag, which SessionBase methods set it, and the three conditions SessionMiddleware checks before saving and sending the cookie.
Weigh SESSION_SAVE_EVERY_REQUEST's write cost against sliding expiry, and design session payloads as small JSON of IDs rather than objects.
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