ZetCode

Django sessions and cookies

last modified November 13, 2026 Python 3.15

HTTP is stateless, so a web application has to recognize the returning visitor in some other way. In this article we use the two tools that Django offers for this: sessions, which keep the data on the server, and cookies, which keep it in the browser. We configure the session cookie, count visits and fill a shopping cart with request.session, look at how the data is stored, compare the session backends, set plain and signed cookies, and test the code. The examples use Django 6.1 and Python 3.15.

How sessions work

A session is a dictionary that belongs to one browser. The data stays on the server, in the database by default; the browser gets only a random key in a cookie, which is sent back with every request. The middleware SessionMiddleware reads the cookie at the beginning of a request and loads the data lazily, the first time the code uses request.session; at the end of the request it saves the dictionary if it was modified and sets the cookie. A response that depended on the session carries the header Vary: Cookie, so that caches do not mix up the visitors. The values of a session are serialized to JSON, so they may be strings, numbers, booleans, lists, dictionaries, and None.

A cookie is a small piece of text that the server sends in the header Set-Cookie and the browser returns in the header Cookie. Everything in a cookie belongs to the user, who can read it, delete it, or change it; so a cookie is good for harmless preferences and for the session key, and the data that matters is kept in the session.

The example project

The project is created with uv; the steps are explained in Django introduction. The commands below create a project directory, install Django, generate the project with the configuration package config, start the app shop, and create the folder demos for demo scripts.

$ mkdir sessions
$ cd sessions
$ uv init --bare
$ uv add django
$ uv run django-admin startproject config .
$ uv run python manage.py startapp shop
$ mkdir demos

The trailing dot in startproject config . creates manage.py and the config package in the current directory instead of one level deeper. A new app is ignored by Django until it is listed in INSTALLED_APPS, so we add the app to the list in config/settings.py; the other entries are generated by startproject.

config/settings.py
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'shop',
]

The listings in this article are of two kinds. A listing with a solid blue border and the label Project file is a file of the application: models, views, templates, tests, and settings. A listing with a dashed amber border and the label Demo script is a small helper in the demos folder; it sets up Django, runs a few lines of code, and prints the results, so that we can watch the behaviour. Demo scripts are not part of the application; we run one from the project directory with uv run python -m demos.name.

The app shop has a few small views that use the session and cookies. The table of the sessions is created by migrate, since django.contrib.sessions is a part of INSTALLED_APPS, and the middleware is in MIDDLEWARE of the generated settings.

$ uv run python manage.py migrate | tail -n 2
  Applying auth.0012_alter_user_first_name_max_length... OK
  Applying sessions.0001_initial... OK

Session settings

The session cookie is configured by the settings with the prefix SESSION_. We rename the cookie, so that it does not clash with the cookies of other projects on localhost, shorten its life to one week, and spell out the security options. SESSION_COOKIE_HTTPONLY hides the cookie from JavaScript, so that a script injected into a page cannot steal the session. SESSION_COOKIE_SAMESITE controls whether the browser sends the cookie with requests that come from other sites; Lax sends it with top-level navigation, but not with embedded requests and forms posted from another site. SESSION_COOKIE_SECURE restricts the cookie to HTTPS; it is off here, because the development server speaks plain HTTP, and must be on in production.

config/settings.py (session settings)
SESSION_COOKIE_NAME = 'shopsid'
SESSION_COOKIE_AGE = 60 * 60 * 24 * 7
SESSION_COOKIE_SAMESITE = 'Lax'
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SECURE = False
SESSION_SAVE_EVERY_REQUEST = False
SESSION_EXPIRE_AT_BROWSER_CLOSE = False

The script compares the project values with the defaults from django.conf.global_settings. The option SESSION_SAVE_EVERY_REQUEST would save the session and renew its expiry on every request, not only when it changes; SESSION_EXPIRE_AT_BROWSER_CLOSE makes a cookie that the browser deletes when it is closed.

demos/settings.py
import os

import django

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()

from django.conf import global_settings, settings

names = ['SESSION_ENGINE', 'SESSION_COOKIE_NAME', 'SESSION_COOKIE_AGE', 'SESSION_COOKIE_SAMESITE',
         'SESSION_COOKIE_HTTPONLY', 'SESSION_COOKIE_SECURE', 'SESSION_COOKIE_DOMAIN',
         'SESSION_COOKIE_PATH', 'SESSION_SAVE_EVERY_REQUEST', 'SESSION_EXPIRE_AT_BROWSER_CLOSE',
         'SESSION_SERIALIZER']
print(f'{"setting":34} {"default":42} project')
for name in names:
    default, value = getattr(global_settings, name), getattr(settings, name)
    mark = '' if default == value else '   <-- changed'
    print(f'{name:34} {default!s:42} {value}{mark}')

Only the name and the age differ from the defaults. The serializer is the JSON one; a pickle serializer existed in older versions, but it is dangerous and was removed in Django 5.0.

$ uv run python -m demos.settings
setting                            default                                    project
SESSION_ENGINE                     django.contrib.sessions.backends.db        django.contrib.sessions.backends.db
SESSION_COOKIE_NAME                sessionid                                  shopsid   <-- changed
SESSION_COOKIE_AGE                 1209600                                    604800   <-- changed
SESSION_COOKIE_SAMESITE            Lax                                        Lax
SESSION_COOKIE_HTTPONLY            True                                       True
SESSION_COOKIE_SECURE              False                                      False
SESSION_COOKIE_DOMAIN              None                                       None
SESSION_COOKIE_PATH                /                                          /
SESSION_SAVE_EVERY_REQUEST         False                                      False
SESSION_EXPIRE_AT_BROWSER_CLOSE    False                                      False
SESSION_SERIALIZER                 django.contrib.sessions.serializers.JSONSerializer django.contrib.sessions.serializers.JSONSerializer

Using request.session

The object request.session behaves like a dictionary: it supports reading with [] and get(), assignment, del, pop(), and in. The view visits counts the requests of a visitor. The view cart_add keeps a dictionary of the items in the session: it reads the cart, changes it, and assigns it back. The assignment is important, as we shall see later. The method flush() in the view reset removes the data and the session row, and tells the browser to delete the cookie; a logout does the same.

shop/views.py
from django.core.signing import BadSignature
from django.http import HttpResponse, HttpResponseBadRequest, JsonResponse
from django.views.decorators.http import require_POST

THEMES = {'light', 'dark'}


def text(message, **kwargs):
    return HttpResponse(message + '\n', content_type='text/plain', **kwargs)


def visits(request):
    count = request.session.get('visits', 0) + 1
    request.session['visits'] = count
    return text(f'Visit number {count}')


def set_theme(request, name):
    if name not in THEMES:
        return text('Unknown theme', status=400)
    response = text(f'Theme set to {name}')
    response.set_cookie('theme', name, max_age=60 * 60 * 24 * 365, samesite='Lax')
    return response


def show_theme(request):
    theme = request.COOKIES.get('theme')
    if theme not in THEMES:
        theme = 'light'
    return text(f'Theme: {theme}')


def remember(request, name):
    response = text(f'I will remember {name}')
    response.set_signed_cookie('remember', name, salt='remember', max_age=3600,
                               httponly=True, samesite='Lax')
    return response


def whoami(request):
    try:
        name = request.get_signed_cookie('remember', salt='remember', max_age=3600)
    except KeyError:
        return text('Nobody here')
    except BadSignature:
        return text('The cookie was changed or has expired', status=400)
    return text(f'Hello, {name}')


@require_POST
def cart_add(request, sku):
    cart = request.session.get('cart', {})
    cart[sku] = cart.get(sku, 0) + 1
    request.session['cart'] = cart
    return JsonResponse(cart)


def cart_show(request):
    return JsonResponse(request.session.get('cart', {}))


@require_POST
def reset(request):
    request.session.flush()
    return text('Session deleted')

The same file contains the views for the cookies, which we discuss below. The views that change state, cart_add and reset, accept only POST. The URL patterns follow.

shop/urls.py
from django.urls import path

from . import views

urlpatterns = [
    path('visits/', views.visits),
    path('theme/', views.show_theme),
    path('theme/<slug:name>/', views.set_theme),
    path('remember/<slug:name>/', views.remember),
    path('whoami/', views.whoami),
    path('cart/', views.cart_show),
    path('cart/add/<slug:sku>/', views.cart_add),
    path('reset/', views.reset),
]

The root URL configuration includes the patterns of the app at the top level.

config/urls.py
from django.urls import include, path

urlpatterns = [
    path('', include('shop.urls')),
]

The session middleware and the cookies are tied to the HTTP exchange, so the best way to see them is the real server. We start the development server in one terminal and use curl in another.

$ uv run python manage.py runserver

The option -i of curl prints the response headers, and -c stores the received cookies in a file that the option -b sends back. The first response has the header Set-Cookie with the random key and the attributes from our settings: the expiry date, HttpOnly, Max-Age of one week, Path=/, and SameSite=Lax. With the cookie jar the next requests belong to the same session, so the counter increases. The jar file shows what the client keeps; the prefix #HttpOnly_ is how curl marks the cookie. A request without the cookie starts a new session.

$ curl -si -c jar.txt http://127.0.0.1:8000/visits/
HTTP/1.1 200 OK
Date: Fri, 13 Nov 2026 20:33:05 GMT
Server: WSGIServer CPython/3.15.2
Content-Type: text/plain
X-Frame-Options: DENY
Content-Length: 15
Vary: Cookie
X-Content-Type-Options: nosniff
Referrer-Policy: same-origin
Cross-Origin-Opener-Policy: same-origin
Set-Cookie: shopsid=78f31pzlmiwuinqyg039wnzw6kxzph82; expires=Fri, 20 Nov 2026 20:33:05 GMT; HttpOnly; Max-Age=604800; Path=/; SameSite=Lax

Visit number 1

$ curl -s -b jar.txt -c jar.txt http://127.0.0.1:8000/visits/
Visit number 2

$ curl -s -b jar.txt -c jar.txt http://127.0.0.1:8000/visits/
Visit number 3

$ cat jar.txt
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.

#HttpOnly_127.0.0.1	FALSE	/	FALSE	1795206785	shopsid	78f31pzlmiwuinqyg039wnzw6kxzph82

$ curl -s http://127.0.0.1:8000/visits/
Visit number 1

Shopping cart in the session

The script uses the test client, which keeps cookies like a browser, to simulate two visitors. The first adds items to the cart; the second only looks. Each client gets its own session, and the data is separated. The client stores the cookie named by our setting. The GET request to a view that accepts only POST fails with 405. The POST to reset deletes the session.

demos/flow.py
import os

import django

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()

from django.test import Client
from django.test.utils import setup_test_environment

setup_test_environment()

import logging

from django.contrib.sessions.models import Session

logging.getLogger('django.request').setLevel(logging.ERROR)
Session.objects.all().delete()
alice, bob = Client(), Client()

print(alice.post('/cart/add/book/').json())
print(alice.post('/cart/add/book/').json())
print(alice.post('/cart/add/pen/').json())
print('alice cart:', alice.get('/cart/').json())
print('bob cart:  ', bob.get('/cart/').json())
print('cookies of alice:', list(alice.cookies))
print('cookies of bob:  ', list(bob.cookies))
print('sessions in the database:', Session.objects.count())

print()
print('GET /cart/add/book/:', alice.get('/cart/add/book/').status_code)
print('POST /reset/:', alice.post('/reset/').content.decode().strip())
print('alice cart after reset:', alice.get('/cart/').json())
print('sessions in the database:', Session.objects.count())
print('cookie after reset:', alice.cookies['shopsid'].value or '(empty)',
      '| max-age:', alice.cookies['shopsid']['max-age'])

The cart of the first visitor holds the two books and the pen across the requests, and the other visitor's cart is empty. There is one row in the database table, since the second client never changed its session, and Django does not create a row for an untouched one. After flush() the row is gone, and the response replaces the cookie with an empty one that expires at once, Max-Age=0.

$ uv run python -m demos.flow
{'book': 1}
{'book': 2}
{'book': 2, 'pen': 1}
alice cart: {'book': 2, 'pen': 1}
bob cart:   {}
cookies of alice: ['shopsid']
cookies of bob:   []
sessions in the database: 1

GET /cart/add/book/: 405
POST /reset/: Session deleted
alice cart after reset: {}
sessions in the database: 0
cookie after reset: (empty) | max-age: 0

Inside the session store

The class SessionStore of the backend can be used without a request, which shows what the middleware does. The rows are the instances of the model Session with the fields session_key, session_data, and expire_date. The script saves a session and looks at its row, plays with the expiry, shows the effect of changing a mutable value, rotates the key, stores a value that JSON cannot represent, and removes the expired rows.

demos/store.py
import os

import django

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()

from datetime import datetime

from django.contrib.sessions.backends.db import SessionStore
from django.contrib.sessions.models import Session
from django.core.management import call_command
from django.utils import timezone

Session.objects.all().delete()

store = SessionStore()
store['visits'] = 1
store['cart'] = {'book': 2}
print('key before save:', store.session_key)
store.save()
print('key after save:', len(store.session_key), 'characters')
print('rows in django_session:', Session.objects.count())

row = Session.objects.get(pk=store.session_key)
print('session_data:', row.session_data[:48] + '...')
print('decoded:', store.decode(row.session_data))
print('expires in:', round((row.expire_date - timezone.now()).total_seconds()), 'seconds')
print('get_expiry_age():', store.get_expiry_age())

print()
store.set_expiry(300)
print('set_expiry(300):', store.get_expiry_age(), '| at browser close:', store.get_expire_at_browser_close())
store.set_expiry(0)
print('set_expiry(0):', store.get_expire_at_browser_close())
store.set_expiry(None)
print('set_expiry(None):', store.get_expiry_age())

print()
store.save()
store = SessionStore(store.session_key)
print('modified after loading:', store.modified)
store['cart']['pen'] = 1
print('modified after a change inside the dict:', store.modified)
store['cart'] = store['cart']
print('modified after assigning the key again:', store.modified)

print()
old_key = store.session_key
store.cycle_key()
print('cycle_key(): key changed:', store.session_key != old_key, '| data kept:', dict(store))
print('rows:', Session.objects.count())

try:
    store['when'] = datetime(2026, 11, 13)
    store.save()
except TypeError as error:
    print('datetime value:', error)
del store['when']

print()
Session.objects.update(expire_date=timezone.now() - timezone.timedelta(days=1))
print('expired rows before clearsessions:', Session.objects.count())
call_command('clearsessions')
print('rows after clearsessions:', Session.objects.count())

store = SessionStore()
store['x'] = 1
store.save()
print('rows:', Session.objects.count())
store.flush()
print('rows after flush():', Session.objects.count(), '| key:', store.session_key)

The key is created at the first save; it has 32 characters, the alphabet of lower case letters and digits. The column session_data holds the base64-encoded JSON, followed by a timestamp and a signature, so that a modified row is detected when it is loaded. set_expiry() accepts a number of seconds, 0 for a cookie that lasts until the browser is closed, a datetime, or None to return to SESSION_COOKIE_AGE. The next part is the well-known trap: a change made inside a value, such as store['cart']['pen'] = 1, does not set the flag modified, so the middleware would not save it. We assign the key again, or set modified = True, which is why the cart view assigns the cart back. The method cycle_key() keeps the data and changes the key; Django calls it on login, to prevent session fixation, an attack in which the attacker lets the victim log in with a session key that the attacker knows. A value like a datetime must be converted to a string or a number first. Expired rows are not deleted automatically; the command clearsessions removes them, and a production site runs it from cron once a day. The last lines show that flush() deletes the row and forgets the key.

$ uv run python -m demos.store
key before save: None
key after save: 32 characters
rows in django_session: 1
session_data: eyJ2aXNpdHMiOjEsImNhcnQiOnsiYm9vayI6Mn19:1xRxxK:...
decoded: {'visits': 1, 'cart': {'book': 2}}
expires in: 604800 seconds
get_expiry_age(): 604800

set_expiry(300): 300 | at browser close: False
set_expiry(0): True
set_expiry(None): 604800

modified after loading: False
modified after a change inside the dict: False
modified after assigning the key again: True

cycle_key(): key changed: True | data kept: {'visits': 1, 'cart': {'book': 2, 'pen': 1}}
rows: 1
datetime value: Object of type datetime is not JSON serializable

expired rows before clearsessions: 1
rows after clearsessions: 0
rows: 1
rows after flush(): 0 | key: None

Session backends

The setting SESSION_ENGINE selects where the data lives. The database backend, the default, is reliable and needs no setup. The cache backend keeps the data only in the cache, which is fast, but the sessions disappear when the cache is cleared or restarted. The backend cached_db writes to both and reads from the cache first. The file backend keeps a file per session in SESSION_FILE_PATH. The backend signed_cookies puts the whole data into the cookie, signed with the SECRET_KEY, and the server stores nothing. The script saves the same data with each backend and loads it back.

demos/engines.py
import os

import django

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()

import tempfile
from importlib import import_module

from django.test.utils import override_settings

engines = [
    'django.contrib.sessions.backends.db',
    'django.contrib.sessions.backends.cache',
    'django.contrib.sessions.backends.cached_db',
    'django.contrib.sessions.backends.file',
    'django.contrib.sessions.backends.signed_cookies',
]

with tempfile.TemporaryDirectory() as folder, override_settings(SESSION_FILE_PATH=folder):
    for engine in engines:
        store = import_module(engine).SessionStore()
        store['user'] = 'alice'
        store['cart'] = {'book': 2, 'pen': 1}
        store.save()
        key = store.session_key
        print(f'{engine.rsplit(".", 1)[1]:15} key of {len(key):3} characters: {key[:36]}')
        reloaded = import_module(engine).SessionStore(key)
        print(f'{"":15} loaded again: {dict(reloaded)}')

For the first four backends the key is a random identifier. For signed_cookies the key is the data itself, so it has 107 characters here and grows with the data; the browsers limit a cookie to about 4 KB. The signature protects the content from changes, but it is not encrypted, so the user can read it and must not receive secrets. Such a session cannot be invalidated on the server, and an old cookie stays valid until it expires. For these reasons the database or cached_db backends are the safer choice.

$ uv run python -m demos.engines
db              key of  32 characters: fh65fnzgtw2yqd48g7d1zqzib9p94m44
                loaded again: {'user': 'alice', 'cart': {'book': 2, 'pen': 1}}
cache           key of  32 characters: qnea6hn9xg5yaexqpf5v9u1943lyk4ox
                loaded again: {'user': 'alice', 'cart': {'book': 2, 'pen': 1}}
cached_db       key of  32 characters: xkpmf7wjkgdjhm7dd0uvryb3qw9efur7
                loaded again: {'user': 'alice', 'cart': {'book': 2, 'pen': 1}}
file            key of  32 characters: yyf047zs58v3308yg0qghdhaegz0s7y7
                loaded again: {'user': 'alice', 'cart': {'book': 2, 'pen': 1}}
signed_cookies  key of 107 characters: eyJ1c2VyIjoiYWxpY2UiLCJjYXJ0Ijp7ImJv
                loaded again: {'user': 'alice', 'cart': {'book': 2, 'pen': 1}}

Cookies

Not every piece of data needs a session. A preference such as the theme of a page can be stored in a cookie, without a row in the database. The method response.set_cookie() takes the name, the value, and the options: max_age is the lifetime in seconds, secure sends the cookie only over HTTPS, httponly hides it from scripts, samesite limits cross-site sending, and domain and path select the URLs to which it is sent. Without max_age the cookie is deleted when the browser closes. The cookies of a request are in the dictionary request.COOKIES, and the value is always a string from the user, so the view show_theme checks it against the set of known themes.

The signed cookies solve a different problem: the user can read the value, but cannot change it. The method set_signed_cookie() appends a timestamp and a signature, computed with the SECRET_KEY and an optional salt, which makes the signatures of different purposes incompatible. The method get_signed_cookie() checks the signature and the age given in max_age; it raises KeyError when the cookie is missing and BadSignature (also SignatureExpired) when the check fails, or returns the default if one is given. The script reads and sets the cookies, and then changes them as a user could. It lowers the log level of django.request, to keep the output clean.

demos/cookies.py
import os

import django

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()

from django.test import Client
from django.test.utils import setup_test_environment

setup_test_environment()

import logging

logging.getLogger('django.request').setLevel(logging.ERROR)
client = Client()


def show(label, response):
    print(f'{label}: {response.status_code} {response.content.decode().strip()!r}')
    for name, morsel in response.cookies.items():
        attributes = {key: value for key, value in morsel.items() if value}
        print(f'    {name} = {morsel.value[:28]}{"..." if len(morsel.value) > 28 else ""}')
        print(f'    {attributes}')


show('visit', client.get('/visits/'))
show('visit again', client.get('/visits/'))
show('theme', client.get('/theme/dark/'))
show('theme read', client.get('/theme/'))
show('unknown theme', client.get('/theme/blue/'))
client.cookies['theme'] = '<script>'
show('hostile cookie', client.get('/theme/'))

print()
show('remember', client.get('/remember/alice/'))
show('whoami', client.get('/whoami/'))
value = client.cookies['remember'].value
print('signed value:', value)

client.cookies['remember'] = value.replace('alice', 'admin', 1)
show('changed value', client.get('/whoami/'))
client.cookies['remember'] = value.rsplit(':', 1)[0] + ':' + 'x' * 27
show('wrong signature', client.get('/whoami/'))

The session cookie and the theme cookie carry the attributes that the settings and the arguments gave them; the session cookie has httponly, the theme cookie has not. The hostile value sent in the theme cookie is ignored, and the unknown theme in the URL is answered with 400. The signed value has three parts separated by colons: the value, the timestamp in base 62, and the signature. Changing the name to admin or replacing the signature makes get_signed_cookie() raise BadSignature, and the view returns 400. A signed cookie is still not a safe place for secrets, but it can safely carry an identifier that the server later trusts.

$ uv run python -m demos.cookies
visit: 200 'Visit number 1'
    shopsid = eoaqepa1gv1le6u13aaw7ajjqm05...
    {'expires': 'Fri, 20 Nov 2026 20:33:07 GMT', 'path': '/', 'max-age': 604800, 'httponly': True, 'samesite': 'Lax'}
visit again: 200 'Visit number 2'
    shopsid = eoaqepa1gv1le6u13aaw7ajjqm05...
    {'expires': 'Fri, 20 Nov 2026 20:33:07 GMT', 'path': '/', 'max-age': 604800, 'httponly': True, 'samesite': 'Lax'}
theme: 200 'Theme set to dark'
    theme = dark
    {'expires': 'Sat, 13 Nov 2027 20:33:07 GMT', 'path': '/', 'max-age': 31536000, 'samesite': 'Lax'}
theme read: 200 'Theme: dark'
unknown theme: 400 'Unknown theme'
hostile cookie: 200 'Theme: light'

remember: 200 'I will remember alice'
    remember = alice:1xRxxL:vAmiU69iwtWoVDj...
    {'expires': 'Fri, 13 Nov 2026 21:33:07 GMT', 'path': '/', 'max-age': 3600, 'httponly': True, 'samesite': 'Lax'}
whoami: 200 'Hello, alice'
signed value: alice:1xRxxL:vAmiU69iwtWoVDjbfikncsW1lOR5ACMjmsVEW3KnQzM
changed value: 400 'The cookie was changed or has expired'
wrong signature: 400 'The cookie was changed or has expired'

Testing sessions

The test client stores cookies between requests, so the tests can follow a visitor. The property client.session returns the session of the client, or creates it; after we change it we must call save(), and the next request carries the data. The decorator override_settings switches the backend for a test. The tests check the counter, the separation of the visitors, the cart, the method restriction, the deletion by flush(), the cookie attributes, both kinds of cookies, and the signed-cookie backend.

shop/tests.py
from django.test import Client, TestCase, override_settings


class SessionTests(TestCase):

    def test_counter_lives_in_the_session(self):
        for expected in (1, 2, 3):
            self.assertContains(self.client.get('/visits/'), f'Visit number {expected}')

    def test_sessions_are_separate(self):
        self.client.post('/cart/add/book/')
        other = Client()
        self.assertEqual(other.get('/cart/').json(), {})
        self.assertEqual(self.client.get('/cart/').json(), {'book': 1})

    def test_cart_accumulates(self):
        self.client.post('/cart/add/book/')
        response = self.client.post('/cart/add/book/')
        self.assertEqual(response.json(), {'book': 2})

    def test_cart_add_needs_post(self):
        self.assertEqual(self.client.get('/cart/add/book/').status_code, 405)

    def test_reset_deletes_the_session(self):
        self.client.post('/cart/add/book/')
        self.client.post('/reset/')
        self.assertEqual(self.client.get('/cart/').json(), {})

    def test_session_cookie_attributes(self):
        response = self.client.get('/visits/')
        morsel = response.cookies['shopsid']
        self.assertTrue(morsel['httponly'])
        self.assertEqual(morsel['samesite'], 'Lax')
        self.assertEqual(morsel['max-age'], 60 * 60 * 24 * 7)

    def test_session_can_be_prepared_in_a_test(self):
        session = self.client.session
        session['visits'] = 41
        session.save()
        self.assertContains(self.client.get('/visits/'), 'Visit number 42')

    @override_settings(SESSION_ENGINE='django.contrib.sessions.backends.signed_cookies')
    def test_signed_cookie_engine_keeps_data_in_the_cookie(self):
        self.client.post('/cart/add/book/')
        self.assertEqual(self.client.get('/cart/').json(), {'book': 1})
        self.assertGreater(len(self.client.cookies['shopsid'].value), 40)


class CookieTests(TestCase):

    def test_theme_cookie(self):
        response = self.client.get('/theme/dark/')
        self.assertEqual(response.cookies['theme'].value, 'dark')
        self.assertContains(self.client.get('/theme/'), 'Theme: dark')

    def test_unknown_theme_is_rejected(self):
        self.assertEqual(self.client.get('/theme/blue/').status_code, 400)

    def test_cookie_value_is_not_trusted(self):
        self.client.cookies['theme'] = 'anything'
        self.assertContains(self.client.get('/theme/'), 'Theme: light')

    def test_signed_cookie_round_trip(self):
        self.client.get('/remember/alice/')
        self.assertContains(self.client.get('/whoami/'), 'Hello, alice')

    def test_tampered_signed_cookie_is_refused(self):
        self.client.get('/remember/alice/')
        value = self.client.cookies['remember'].value
        self.client.cookies['remember'] = value.replace('alice', 'admin', 1)
        self.assertEqual(self.client.get('/whoami/').status_code, 400)

    def test_no_cookie(self):
        self.assertContains(self.client.get('/whoami/'), 'Nobody here')

All fourteen tests pass. The attributes are read from the morsel of the response cookie, where they have the lower-case keys that the standard library uses.

$ uv run python manage.py test
Found 14 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
..............
----------------------------------------------------------------------
Ran 14 tests in 0.020s

OK
Destroying test database for alias 'default'...

Source

Django documentation: How to use sessions

Django documentation: Session settings

Django documentation: Cryptographic signing

In this article we have configured the session cookie, kept a counter and a cart in request.session, looked at the session store and its backends, set plain and signed cookies, and tested the code. Related articles are Django authentication, Django HttpResponse, and Django HttpRequest.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Django tutorials.