Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Django  Module 2 – URLs, views, templates and static files

Views, requests and responses

Write Django views that read query strings, forms, headers and cookies, and answer with HTML, JSON, files and redirects under the right status code.

  • Beginner
  • 15 minutes
  • Examples run with Python 3.14.8, Django 6.1.2 and Pyodide 314.0.7
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Read query strings, form data, headers and cookies from an HttpRequest, with getlist() where a key repeats
  • Choose the response class and the status code for HTML, JSON, a file download and a redirect
  • Use render(), redirect(), get_object_or_404() and the HTTP method decorators in a view
  • Apply Post/Redirect/Get so that refreshing a page never sends a form twice

Before you start

What you’ll learn · Before you start

What you will learn

  • Read query strings, form data, headers and cookies from an HttpRequest, with getlist() where a key repeats
  • Choose the response class and the status code for HTML, JSON, a file download and a redirect
  • Use render(), redirect(), get_object_or_404() and the HTTP method decorators in a view
  • Apply Post/Redirect/Get so that refreshing a page never sends a form twice

Before you start

On this page

A resident posts a notice, refreshes the page, confirms the browser’s question, and Nivas now holds the notice 2 times. The view decided that, as it decides every answer: what it reads from the request, which response it sends back, and which status code tells the browser what happened.

This lesson writes the notices views of Nivas: a list filtered by its query string, one notice as JSON, a form that saves and redirects, and a CSV download. You will read query strings, form fields, headers and cookies, pick the response class and the status code for each case, answer wrong methods with 405, and apply Post/Redirect/Get so that a refresh never posts twice.

A view reads one HttpRequest and returns one HttpResponse

A view: In Django, the function (or class) called for a URL: it receives the HttpRequest and the values the URL pattern captured, and returns an HttpResponse or raises an exception such as Http404. More in “How Django handles a request” is a function that Django calls with one HttpRequest: The object Django builds for each request and passes to the view as its first argument: the method, the path, GET, POST, the headers, COOKIES and the body, and later attributes that middleware adds, such as user., plus the values its route captured, and that returns one HttpResponse. Everything the browser sent is on that request object, already taken apart. Step through where each part of one request lands:

Interactive · step by step Where each part of a request lands on the HttpRequest Step through the parts of GET /notices/?page=2&sort=newest, from the HTTP request on the left to what the view reads on the right.
Where each part of a request lands on the HttpRequest, step 5 of 5. Showing Request line, Headers, Cookie header, Body, method and path, request.GET, request.headers, request.COOKIES, request.POST and .body. In focus: Body, request.POST and .body.the query stringRequest lineGET /notices/?page=2…HeadersAccept-Language: hi-INCookie headertheme=darkBodyempty for a GETmethod and path'GET', '/notices/'request.GETa QueryDict: page, sortrequest.headersany case of the namerequest.COOKIES{'theme': 'dark'}request.POST and .bodyform fields; raw bytes

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. The request line. The first line of the HTTP request holds the method and the target: GET /notices/?page=2&sort=newest. Django splits it: request.method is GET and request.path is /notices/, without the query string.
  2. The query string. Everything after the ? becomes request.GET, a QueryDict: request.GET.get('page') is the string '2', not a number. It is filled for every method, POST requests included.
  3. Headers. Each header lands in request.headers, which ignores case: 'Accept-Language' and 'accept-language' give the same 'hi-IN'. request.META has it too, as HTTP_ACCEPT_LANGUAGE.
  4. Cookies. The Cookie header is parsed into request.COOKIES, a plain dictionary of strings: request.COOKIES['theme'] is 'dark'.
  5. The body. The body is empty for this GET, so request.body is the bytes b"". For a form sent with POST, Django parses the fields into request.POST; any other body, JSON for example, stays as raw bytes in request.body alone.

This one-file app sends that request through Django’s test client, with a query string, a header and a cookie, and its view prints what it can read:

What a view can read from one request Python · request_parts.py
"""What a view can read from one request: Django's test client sends it, and the view prints what it finds."""

import django
from django.conf import settings

settings.configure(ALLOWED_HOSTS=['testserver'], ROOT_URLCONF=__name__)
django.setup()

from django.http import HttpResponse  # noqa: E402
from django.test import Client  # noqa: E402
from django.urls import path  # noqa: E402


def notice_list(request):
    print('request.method          ', request.method)
    print('request.path            ', request.path)
    print("request.GET.get('page') ", repr(request.GET.get('page')))
    print("request.GET['sort']     ", repr(request.GET['sort']))
    print("request.headers['Accept-Language']", repr(request.headers['Accept-Language']))
    print("request.headers['accept-language']", repr(request.headers['accept-language']))
    print("request.META['HTTP_ACCEPT_LANGUAGE']", repr(request.META['HTTP_ACCEPT_LANGUAGE']))
    print("request.COOKIES['theme']", repr(request.COOKIES['theme']))
    print('request.body            ', repr(request.body))
    return HttpResponse('ok')


urlpatterns = [path('notices/', notice_list)]

client = Client()
client.cookies['theme'] = 'dark'
client.get('/notices/', {'page': '2', 'sort': 'newest'}, headers={'Accept-Language': 'hi-IN'})

Output

request.method           GET
request.path             /notices/
request.GET.get('page')  '2'
request.GET['sort']      'newest'
request.headers['Accept-Language'] 'hi-IN'
request.headers['accept-language'] 'hi-IN'
request.META['HTTP_ACCEPT_LANGUAGE'] 'hi-IN'
request.COOKIES['theme'] 'dark'
request.body             b''

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 request_parts.py

Three details decide how you read a request. request.path is the path alone; the query string is parsed into request.GET, whatever the method. request.headers ignores the case of a header’s name, which HTTP itself does, so both spellings in the run found 'hi-IN', and it takes _ for -, so a template can read request.headers.accept_language; request.META holds the same header renamed HTTP_ACCEPT_LANGUAGE, beside server details such as the client’s address. And nothing is converted for you: the page is the string '2', the cookie the string 'dark' and the body raw bytes, because a request is text from a client you do not control.

Every value in a request comes from the client

A query string, a form field, a header or a cookie can hold anything anyone types, whatever your own pages send. Convert each value you use, check it against what you allow, and fall back to a safe default, as the exercise of this lesson does with the filters of the notices list.

request.GET and request.POST hold several values per key

A query string may repeat a key. A filter for two categories arrives as ?category=water&category=lift, and an HTML form with several checkboxes of the same name posts the same way. So request.GET and request.POST are each a QueryDict: Django's dictionary for a query string or form data, as in request.GET and request.POST: one key can hold several values, getlist() returns them all, and the copies a request gives a view cannot be changed., a dictionary that keeps every value of a key:

QueryDict: several values per key, and read-only Python · querydict.py
"""request.GET and request.POST are QueryDicts: a key can hold several values, and in a request they are read-only."""

import django
from django.conf import settings

settings.configure()
django.setup()

from django.http import QueryDict  # noqa: E402
from django.utils.datastructures import MultiValueDictKeyError  # noqa: E402

query = QueryDict('category=water&category=lift&page=2')  # what ?category=water&category=lift&page=2 gives
print("query['category']        ", repr(query['category']))
print("query.get('category')    ", repr(query.get('category')))
print("query.getlist('category')", repr(query.getlist('category')))
print("query.get('page')        ", repr(query.get('page')), '(always a string)')
print("query.get('sort', 'new') ", repr(query.get('sort', 'new')))
print("query.getlist('sort')    ", repr(query.getlist('sort')))
try:
    query['sort']
except MultiValueDictKeyError as error:
    print("query['sort']             MultiValueDictKeyError:", error)
try:
    query['page'] = '3'
except AttributeError as error:
    print("query['page'] = '3'       AttributeError:", error)
changed = query.copy()
changed['page'] = '3'
print('query.copy() with page 3 ', changed.urlencode())

Output

query['category']         'lift'
query.get('category')     'lift'
query.getlist('category') ['water', 'lift']
query.get('page')         '2' (always a string)
query.get('sort', 'new')  'new'
query.getlist('sort')     []
query['sort']             MultiValueDictKeyError: 'sort'
query['page'] = '3'       AttributeError: This QueryDict instance is immutable
query.copy() with page 3  category=water&category=lift&page=3

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 querydict.py

The first three lines are the trap. Indexing and get() give the last value, 'lift', so a view that reads request.GET['category'] silently drops water. getlist() returns every value in order, and [] for a key that is missing, which is why the notices list reads its categories with it. For a key that may be missing, use get() with a default; indexing raises MultiValueDictKeyError, and a view that does not catch it answers with a 500 error.

The last lines show that the QueryDicts of a request are read-only. To build a link with another page number, change a copy, request.GET.copy(), and call urlencode() on it.

request.POST has one more limit: Django fills it only for the bodies HTML forms send, application/x-www-form-urlencoded and multipart/form-data. A script that posts JSON finds request.POST empty; the JSON is in request.body, as bytes, for the view to parse with json.loads(). A later section shows a view tripping over exactly this.

Reading a repeated key with get()

request.GET.get('category') for ?category=water&category=lift returns 'lift' and loses water. Read keys that may repeat with request.GET.getlist('category'), and keys with one value with get('page', '1').

Checkpoint

Check: QueryDicts with several values per key

  1. Question 1 of 2 The request is GET /notices/?category=water&category=lift. What does request.GET['category'] return?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 'lift'

    A query string may repeat a key, so request.GET keeps every value. Indexing and get() return the last one; getlist() returns them all, in order, which is what a filter with several categories needs.

  2. Question 2 of 2 A script posts the JSON {"title": "Water off at noon", "category": "water"} to the notices form view, which reads request.POST.get('title'). What does the view see?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: None, because request.POST holds form fields only, and the JSON stays in request.body

    request.POST is filled only for HTML form encodings. Any other body, JSON included, is in request.body as bytes, and the view parses it itself with json.loads(), or an API framework does it.

The response class and the status code say what came back

A view chooses two things for its answer: a response class, which sets the body and the Content-Type, and a status code: The three-digit number at the start of an HTTP response that says what happened, such as 200 OK, 201 Created, 302 Found for a redirect, 404 Not Found or 405 Method Not Allowed., which tells the client what happened before it reads a word of the body. This program builds the classes a view uses most, and prints the status line and the headers of each:

Response classes and the status and headers each one sends Python · responses.py
"""The response classes a view returns, and what each one tells the client: the status and the headers."""

import io

import django
from django.conf import settings

settings.configure()
django.setup()

from django.http import (  # noqa: E402
    FileResponse,
    HttpResponse,
    HttpResponseNotAllowed,
    HttpResponsePermanentRedirect,
    HttpResponseRedirect,
    JsonResponse,
)


def show(code, response):
    headers = ', '.join(f'{name}: {value}' for name, value in response.headers.items() if name != 'Content-Length')
    print(f'{code}\n  {response.status_code} {response.reason_phrase}; {headers}')


show("HttpResponse('<p>Saved</p>')", HttpResponse('<p>Saved</p>'))
show("HttpResponse(status=201)", HttpResponse(status=201))
show("HttpResponse(status=204)", HttpResponse(status=204))
show("JsonResponse({'id': 7})", JsonResponse({'id': 7}))
show("JsonResponse([7, 8], safe=False)", JsonResponse([7, 8], safe=False))
try:
    JsonResponse([7, 8])
except TypeError as error:
    print(f'JsonResponse([7, 8])\n  TypeError: {error}')
show("HttpResponseRedirect('/notices/7/')", HttpResponseRedirect('/notices/7/'))
show("HttpResponseRedirect('/notices/7/', preserve_request=True)", HttpResponseRedirect('/notices/7/', preserve_request=True))
show("HttpResponsePermanentRedirect('/board/7/')", HttpResponsePermanentRedirect('/board/7/'))
show("HttpResponseNotAllowed(['GET', 'POST'])", HttpResponseNotAllowed(['GET', 'POST']))
show("FileResponse(..., as_attachment=True, filename='notices.csv')",
     FileResponse(io.BytesIO(b'id,title\n'), as_attachment=True, filename='notices.csv'))

Output

HttpResponse('<p>Saved</p>')
  200 OK; Content-Type: text/html; charset=utf-8
HttpResponse(status=201)
  201 Created; Content-Type: text/html; charset=utf-8
HttpResponse(status=204)
  204 No Content; Content-Type: text/html; charset=utf-8
JsonResponse({'id': 7})
  200 OK; Content-Type: application/json
JsonResponse([7, 8], safe=False)
  200 OK; Content-Type: application/json
JsonResponse([7, 8])
  TypeError: In order to allow non-dict objects to be serialized set the safe parameter to False.
HttpResponseRedirect('/notices/7/')
  302 Found; Content-Type: text/html; charset=utf-8, Location: /notices/7/
HttpResponseRedirect('/notices/7/', preserve_request=True)
  307 Temporary Redirect; Content-Type: text/html; charset=utf-8, Location: /notices/7/
HttpResponsePermanentRedirect('/board/7/')
  301 Moved Permanently; Content-Type: text/html; charset=utf-8, Location: /board/7/
HttpResponseNotAllowed(['GET', 'POST'])
  405 Method Not Allowed; Content-Type: text/html; charset=utf-8, Allow: GET, POST
FileResponse(..., as_attachment=True, filename='notices.csv')
  200 OK; Content-Type: text/csv, Content-Disposition: attachment; filename="notices.csv"

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 responses.py

Class Use it for Status
HttpResponse HTML or text you built 200, or status=201, 204 …
JsonResponse JSON: a dict, or anything with safe=False 200, or status=…
FileResponse a file; as_attachment=True downloads it 200
HttpResponseRedirect send the browser elsewhere 302, or 307
HttpResponsePermanentRedirect an address that moved for good 301, or 308
HttpResponseNotAllowed a method the resource does not take 405, with Allow

The status codes of RFC 9110 are part of the answer: 201 says something was created, 204 that the request worked with nothing to send back, 404 that the resource is missing, 409 that the request conflicts with its current state, and 422 that the data is well formed but fails validation. Two details from the run: JsonResponse([7, 8]) raised a TypeError, because on 6.1 it accepts only a dict unless you pass safe=False; and preserve_request=True turned the redirect from 302 into 307, which asks the browser to repeat the same method and body at the new address.

Version note

The release notes of Django 6.2, still in development, deprecate the safe parameter of JsonResponse and make False its default, so JsonResponse([7, 8]) will work there. On 6.1, keep safe=False for anything but a dict.

An error page is an answer too. With DEBUG = False, Django’s own 404 and 500 pages show no code and no settings; keep it that way in your own views, and never put an exception’s text or a stack trace in a response. OWASP’s Error Handling Cheat Sheet explains why: such details reveal the software and the versions behind a site, which helps an attacker plan, so log them on the server and show the visitor a short message.

HTTP Status Codes Reference Look up any status code, and when a server should send it.

Shortcuts render a page, redirect, and turn a missing row into a 404

Three shortcuts from django.shortcuts do the work most views repeat. render(request, template, context, status=…) fills a template and wraps it in an HttpResponse. redirect() takes a route’s name and its values, reverses them as the previous lesson did, and returns a 302. get_object_or_404(Notice, pk=pk) fetches one row, or raises Http404, which Django turns into a 404 response. The notices views of Nivas use all three; the Notice model has three fields, and the module on models explains the rest:

The notices views and the model they read

notices/views.py · nivas/notices/views.py

import csv
import io

from django.http import FileResponse, HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.http import require_http_methods, require_safe

from .models import Notice

CATEGORIES = ['water', 'lift', 'parking', 'security']


@require_safe
def notice_list(request):
    # ?category=water&category=lift: getlist() keeps every value; unknown categories are ignored.
    chosen = [c for c in request.GET.getlist('category') if c in CATEGORIES]
    notices = Notice.objects.order_by('pk')
    if chosen:
        notices = notices.filter(category__in=chosen)
    return render(request, 'notices/list.html', {'notices': notices, 'chosen': chosen})


@require_safe
def notice_detail(request, pk):
    notice = get_object_or_404(Notice, pk=pk)
    return JsonResponse({'id': notice.pk, 'title': notice.title, 'category': notice.category})


@require_http_methods(['GET', 'POST'])
def notice_create(request):
    if request.method == 'POST':
        title = request.POST.get('title', '').strip()
        category = request.POST.get('category', '')
        if title and category in CATEGORIES:
            notice = Notice.objects.create(title=title, category=category)
            return redirect('notices:detail', pk=notice.pk)  # Post/Redirect/Get
        context = {'categories': CATEGORIES, 'error': 'Give a title and one of the categories.'}
        return render(request, 'notices/form.html', context, status=422)
    return render(request, 'notices/form.html', {'categories': CATEGORIES})


@require_safe
def notice_export(request):
    rows = io.StringIO()
    writer = csv.writer(rows)
    writer.writerow(['id', 'title', 'category'])
    for notice in Notice.objects.order_by('pk'):
        writer.writerow([notice.pk, notice.title, notice.category])
    return FileResponse(io.BytesIO(rows.getvalue().encode()), as_attachment=True, filename='notices.csv')


def flat_notices(request, code):
    return HttpResponse(f'Notices for flat {code}, block {code.block}')

notices/models.py · nivas/notices/models.py

from django.db import models


# The module on models explains fields and migrations. For this lesson a notice has three fields.
class Notice(models.Model):
    title = models.CharField(max_length=120)
    category = models.CharField(max_length=20)
    body = models.TextField(blank=True)

    def __str__(self):
        return self.title

This program sends the requests a browser and a script would send, through the test client, against a test database with three notices in it, and prints what came back:

The notices views through the test client Python · nivas/check_views.py
"""Send the requests a browser and a script would send to the notices views, through Django's test client, and
print what came back: the status, the headers that matter and the body.

Run it next to manage.py. It makes a test database in memory, as manage.py test does, with three notices in it.
"""

import json
import logging
import os
import re

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

import django  # noqa: E402

django.setup()

from django.test import Client  # noqa: E402
from django.test.utils import setup_databases, setup_test_environment  # noqa: E402

setup_test_environment()
setup_databases(verbosity=0, interactive=False, aliases={'default'})
logging.getLogger('django.request').setLevel(logging.CRITICAL)  # the 404, 405 and 422 below are expected

from notices.models import Notice  # noqa: E402

for pk, title, category in [(7, 'Water tank cleaning on Sunday', 'water'),
                            (8, 'Lift in block B under repair', 'lift'),
                            (9, 'Visitors park in P2 this week', 'parking')]:
    Notice.objects.create(pk=pk, title=title, category=category)

client = Client()


def show(request_line, response, *headers):
    print(request_line)
    line = f'  {response.status_code} {response.reason_phrase}'
    for name in headers:
        line += f', {name}: {response[name]}'
    print(line)


response = client.get('/notices/', {'category': ['water', 'lift']})
show('GET /notices/?category=water&category=lift', response, 'Content-Type')
for item in re.findall(r'<li>(.*?)</li>', response.text):
    print(f'    {item}')

response = client.get('/notices/7/')
show('GET /notices/7/', response, 'Content-Type')
print(f'    {response.text}')

show('GET /notices/70/', client.get('/notices/70/'))

response = client.post('/notices/new/', {'title': 'Generator test at 4 pm', 'category': 'security'})
show('POST /notices/new/ with title=Generator test at 4 pm and category=security', response, 'Location')
response = client.get(response['Location'])
show('GET /notices/10/, the page the redirect points to', response)
print(f'    {response.text}')

response = client.post('/notices/new/', json.dumps({'title': 'Water off at noon', 'category': 'water'}),
                       content_type='application/json')
show('POST /notices/new/ with the same kind of data as JSON', response)
print('    ' + re.search(r'role="alert">(.*?)<', response.text).group(1))

show('PUT /notices/new/', client.put('/notices/new/'), 'Allow')
show('HEAD /notices/7/, as a link checker sends it', client.head('/notices/7/'))
show('DELETE /notices/7/', client.delete('/notices/7/'), 'Allow')

response = client.get('/notices/export/')
show('GET /notices/export/', response, 'Content-Type', 'Content-Disposition')
for row in b''.join(response.streaming_content).decode().splitlines():
    print(f'    {row}')

Output

GET /notices/?category=water&category=lift
  200 OK, Content-Type: text/html; charset=utf-8
    Water tank cleaning on Sunday (water)
    Lift in block B under repair (lift)
GET /notices/7/
  200 OK, Content-Type: application/json
    {"id": 7, "title": "Water tank cleaning on Sunday", "category": "water"}
GET /notices/70/
  404 Not Found
POST /notices/new/ with title=Generator test at 4 pm and category=security
  302 Found, Location: /notices/10/
GET /notices/10/, the page the redirect points to
  200 OK
    {"id": 10, "title": "Generator test at 4 pm", "category": "security"}
POST /notices/new/ with the same kind of data as JSON
  422 Unprocessable Content
    Give a title and one of the categories.
PUT /notices/new/
  405 Method Not Allowed, Allow: GET, POST
HEAD /notices/7/, as a link checker sends it
  200 OK
DELETE /notices/7/
  405 Method Not Allowed, Allow: GET, HEAD
GET /notices/export/
  200 OK, Content-Type: text/csv, Content-Disposition: attachment; filename="notices.csv"
    id,title,category
    7,Water tank cleaning on Sunday,water
    8,Lift in block B under repair,lift
    9,Visitors park in P2 this week,parking
    10,Generator test at 4 pm,security

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_views.py

Read it from the top. The list read both categories with getlist() and rendered its template, text/html. Notice 7 came back as JSON, and notice 70, which does not exist, as a 404 from get_object_or_404; a bare Notice.objects.get() would have raised DoesNotExist and answered 500. The form post that worked got a redirect, the subject of the last section. The same data posted as JSON got 422 with the form’s error, because request.POST was empty, as the previous section warned. These requests carry no CSRF token because the test client skips the check that CsrfViewMiddleware makes on every POST, PUT and DELETE; from a real script without a token, they would get 403 before any view ran. The export came back as text/csv with Content-Disposition: attachment, so a browser saves it as notices.csv.

Key idea

A view returns one response, and its class and status code are part of the answer: 404 for a missing row, 422 for data that fails its checks, a redirect after a success.

Checkpoint

Check: Response classes and status codes · render, redirect and get_object_or_404

  1. Question 1 of 2 A view returns JsonResponse([7, 8, 9]) on Django 6.1. What happens?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A TypeError that says to set safe=False to send anything but a dict

    JsonResponse sends a dict by default. Pass safe=False for a list or any other value, or wrap the list in a dict, such as {'notices': [7, 8, 9]}, which leaves room for more keys later.

  2. Question 2 of 2 notice_detail calls get_object_or_404(Notice, pk=pk), and the request is GET /notices/70/, which has no notice. What does the client get?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 404 Not Found

    get_object_or_404 turns "no such row" into the answer the client should see, a 404, in one line. A bare get() would let DoesNotExist escape and become a 500 error.

A method decorator answers the wrong method with 405

Each notices view serves only the methods it was written for, and says so in one line, above its definition. Django’s decorators in django.views.decorators.http check the method before the view’s code runs and answer any other method with HttpResponseNotAllowed, a 405 that lists the methods allowed in its Allow header:

  • @require_http_methods(['GET', 'POST']) on notice_create, which shows the form and accepts it;
  • @require_safe on the views that only read, which allows GET and HEAD;
  • @require_GET and @require_POST for one method each.

The transcript above shows both kinds of refusal for requests that reached the views, and the HEAD request a link checker would send:

Request (and the view’s decorator) Answer
PUT /notices/new/ (require_http_methods) 405, Allow: GET, POST
DELETE /notices/7/ (require_safe) 405, Allow: GET, HEAD
HEAD /notices/7/ (require_safe) 200, as for a GET

The HEAD row is why the read-only views use require_safe rather than require_GET: the decorators page suggests it, because tools such as link checkers rely on HEAD.

Without a decorator, a view that tests if request.method == 'POST': and treats everything else as a GET would answer a DELETE with the list page and 200, telling a script that the delete worked.

Post/Redirect/Get keeps a refresh from sending a form twice

A browser’s refresh button repeats the last request it made. When a view answers a successful POST with a page, that last request is the POST, and refresh sends the form again. Post/Redirect/Get: Answering a successful form POST with a redirect to a page fetched with GET, so that the browser's last request is the GET and refreshing the page never sends the form again. answers the POST with a redirect instead, so the browser’s last request becomes a GET. Step through both, with the redirect and without it:

Interactive · message by message Post/Redirect/Get, and the refresh that saves twice Step through a notice posted and the page refreshed, first with the redirect, then without it.
Post/Redirect/Get, and the refresh that saves twice, step 7 of 7. Messages so far: Browser to notice_create: POST /notices/new/; notice_create to Database: save notice 10; notice_create to Browser: 302 to /notices/10/; Browser to notice_create: GET /notices/10/; Browser to notice_create: GET /notices/10/; Browser to notice_create: POST /notices/new/ again; notice_create to Database: save notice 11.Browserlast request: the POSTnotice_createthe viewDatabase2 copies of one noticePOST /notices/new/save notice 10302 to /notices/10/GET /notices/10/GET /notices/10/POST /notices/new/ againsave notice 11one submission, two rows

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. The form is sent. A resident fills in the form and presses Publish. The browser sends POST /notices/new/ with title and category.
  2. The notice is saved. The view checks the fields and saves the notice. The database gives it the number 10.
  3. The answer is a redirect. Instead of a page, the view answers 302 Found with Location: /notices/10/, the address of the new notice.
  4. The browser asks again. The browser follows the redirect with a GET. The page it shows now came from GET /notices/10/, and that is the last request it remembers.
  5. Refresh is harmless. The resident presses refresh. The browser repeats GET /notices/10/, which reads the notice and saves nothing.
  6. Without the redirect. Now a view that answers the POST with a 200 page itself. The last request the browser remembers is the POST, so refresh asks to confirm form resubmission, and sends it again.
  7. Saved twice. The view cannot tell the second POST from the first, and saves a second copy. The recorded run of prg.py counted 2 notices for one submission.

This program plays the browser, refresh button included, against two views that save the same notice, one that answers the POST itself and one that redirects:

One submission and one refresh, without and with a redirect Python · prg.py
"""Post/Redirect/Get. A resident submits a notice, then presses refresh. The browser repeats its last request: without
a redirect that is the POST, so the notice is saved twice; with one it is a GET, which changes nothing."""

import django
from django.conf import settings

settings.configure(ALLOWED_HOSTS=['testserver'], ROOT_URLCONF=__name__)
django.setup()

from django.http import HttpResponse  # noqa: E402
from django.shortcuts import redirect  # noqa: E402
from django.test import Client  # noqa: E402
from django.urls import path  # noqa: E402

saved = []


def create_and_answer(request):
    saved.append(request.POST['title'])
    return HttpResponse(f'Saved. {len(saved)} notice(s).')


def create_and_redirect(request):
    saved.append(request.POST['title'])
    return redirect('saved')


def saved_page(request):
    return HttpResponse(f'{len(saved)} notice(s).')


urlpatterns = [
    path('answer/new/', create_and_answer),
    path('redirect/new/', create_and_redirect),
    path('saved/', saved_page, name='saved'),
]


class Browser:
    """Remembers the last request it made, as a browser's refresh button does."""

    def __init__(self):
        self.client = Client()
        self.last = None

    def request(self, method, url, data=None):
        response = getattr(self.client, method)(url, data or {})
        self.last = (method, url, data)
        print(f'  {method.upper()} {url} -> {response.status_code}', end='')
        if response.status_code == 302:  # a browser follows a redirect with a GET
            print(f', then GET {response["Location"]}', end='')
            response = self.client.get(response['Location'])
            self.last = ('get', response.wsgi_request.path, None)
        print(f': {response.text}')

    def refresh(self):
        print('  refresh:', end='')
        self.request(*self.last)


for url in ['/answer/new/', '/redirect/new/']:
    saved.clear()
    print('The view answers the POST itself' if url.startswith('/answer') else 'The view answers the POST with a redirect')
    browser = Browser()
    browser.request('post', url, {'title': 'Water tank cleaning on Sunday'})
    browser.refresh()
    print(f'  notices saved: {len(saved)}')

Output

The view answers the POST itself
  POST /answer/new/ -> 200: Saved. 1 notice(s).
  refresh:  POST /answer/new/ -> 200: Saved. 2 notice(s).
  notices saved: 2
The view answers the POST with a redirect
  POST /redirect/new/ -> 302, then GET /saved/: 1 notice(s).
  refresh:  GET /saved/ -> 200: 1 notice(s).
  notices saved: 1

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 prg.py

Without the redirect, one submission and one refresh saved 2 notices; with it, the refresh repeated GET /saved/ and the count stayed at 1. The notices form of Nivas does the same with redirect('notices:detail', pk=notice.pk), which the transcript showed as 302 Found with Location: /notices/10/. Redirect only after a success: a failed form is shown again with its errors and a status such as 422, so nothing was saved that a refresh could repeat.

Answering a successful POST with render()

return render(request, 'notices/done.html', …) after saving leaves the POST as the browser’s last request. Return redirect('notices:detail', pk=notice.pk) instead, and show the confirmation on that page.

Post/Redirect/Get stops the duplicate that a refresh causes. It cannot stop two POSTs that leave before any answer comes back, such as a double click on a slow connection; that needs the server to recognise a request it has already handled, which the interview questions of this lesson take further.

Checkpoint

Check: Method decorators and 405 · Post/Redirect/Get

  1. Question 1 of 2 notice_create is decorated with @require_http_methods(['GET', 'POST']). A PUT /notices/new/ reaches it, as in the recorded run. What comes back?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 405 Method Not Allowed, with the header Allow: GET, POST

    require_http_methods, require_GET and require_POST check the method before the view runs and answer 405 with an Allow header, so a view never has to guess what to do with a method it was not written for.

  2. Question 2 of 2 A view saves a notice and answers the POST with a 200 page. The resident presses refresh and confirms. What happens?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The browser sends the same POST again, and the notice is saved a second time

    Answer a successful POST with a redirect. The browser follows it with a GET, which becomes its last request, and refreshing repeats the GET, which saves nothing.

Practise: filters you can trust

The notices list reads its filters from the query string, where anyone can type anything: a status that does not exist, a page number of minus 3, a flat code twice. Write parse_filters(params), which turns a QueryDict into filters with allowed values and safe defaults. Its seven sample tests feed it hand-made query strings through an object with a getlist() method, as request.GET has one, and they run in your browser:

Exercise · Easy · Python

Turn a query string into filters you can trust

The notices list of Nivas takes its filters from the query string, such as ?status=open&status=closed&flat=B-1203&page=2. Anyone can type any query string, so write parse_filters(params), which turns it into a dictionary the view can trust:

  • 'status': every value of the status key that is 'open', 'closed' or 'pending', in the order they first appear and each only once. When no value is valid, ['open'].
  • 'flat': the last flat value, if it is a flat code: a capital letter from A to F, a hyphen and three or four digits, such as B-1203. Otherwise None.
  • 'page': the last page value as an int, if it is a whole number of 1 or more written in the digits 0 to 9. Otherwise 1.
  • Every other key is ignored.

params works like request.GET: params.getlist(key) returns all the values of a key as strings, in order, and [] when the key is missing. The sample tests pass a small stand-in with that one method. For the example above, parse_filters returns {'status': ['open', 'closed'], 'flat': 'B-1203', 'page': 2}.

Starter code · filters.py

def parse_filters(params):
    """Turn a QueryDict-like object into {'status': [...], 'flat': ..., 'page': ...} with safe values."""
    return {'status': params.getlist('status'), 'flat': None, 'page': 1}
The sample tests · test_filters.py
from urllib.parse import parse_qsl

from filters import parse_filters


class Params:
    """A stand-in for request.GET: getlist() returns every value of a key, as QueryDict does."""

    def __init__(self, query=''):
        self.pairs = parse_qsl(query, keep_blank_values=True)

    def getlist(self, key):
        return [value for name, value in self.pairs if name == key]


def test_the_example_query():
    params = Params('status=open&status=closed&flat=B-1203&page=2')
    assert parse_filters(params) == {'status': ['open', 'closed'], 'flat': 'B-1203', 'page': 2}


def test_an_empty_query_gives_the_defaults():
    assert parse_filters(Params('')) == {'status': ['open'], 'flat': None, 'page': 1}


def test_unknown_statuses_are_dropped_and_repeats_kept_once():
    assert parse_filters(Params('status=open&status=deleted&status=open&status=pending'))['status'] == ['open', 'pending']


def test_no_valid_status_falls_back_to_open():
    assert parse_filters(Params('status=archived&status='))['status'] == ['open']


def test_a_flat_that_is_not_a_flat_code_is_none():
    for flat in ['b-1203', 'G-1203', 'B1203', 'B-12', 'B-12034', '']:
        assert parse_filters(Params(f'flat={flat}'))['flat'] is None, flat


def test_a_page_that_is_not_a_whole_number_of_one_or_more_is_one():
    for page in ['0', '-3', 'two', '2.5', '']:
        assert parse_filters(Params(f'page={page}'))['page'] == 1, page
    assert parse_filters(Params('page=007'))['page'] == 7


def test_the_last_value_counts_and_other_keys_are_ignored():
    params = Params('page=2&page=5&flat=A-101&flat=C-2201&sort=newest')
    assert parse_filters(params) == {'status': ['open'], 'flat': 'C-2201', 'page': 5}
A hint

Read each key with params.getlist(key), never assume it has exactly one value. For status, walk through the list and keep a value only if it is allowed and not kept already. For flat and page, the value that counts is the last one, values[-1], when the list is not empty; check it with re.fullmatch() before you use it.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

REST API Tester (Online API Client) Send GET, POST and PUT requests with your own headers and bodies to an API you run.

Then fifteen questions on views put the six ideas of this lesson to work, from the parts of a request to Post/Redirect/Get. The self-check opens with the idea your earlier answers left weakest, and each choice explains itself:

Practice that adapts to you

Practise this lesson

15 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 15 For GET /notices/?page=2, what is request.path?

    Choose one answer.

    Show the answer: Question 1 of 15

    Answer: /notices/

    request.path is the path alone, with its leading slash. The query string is parsed into request.GET, and get_full_path() gives both together.

  2. Question 2 of 15 A request carries the header Accept-Language: hi-IN. Which expressions give 'hi-IN'? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 2 of 15

    Answer:

    • request.META['HTTP_ACCEPT_LANGUAGE']
    • request.headers['accept-language']
    • request.headers['Accept_Language']

    Read headers with request.headers, which ignores case and accepts underscores for hyphens. request.META has them too, renamed with HTTP_ and underscores, beside server details such as REMOTE_ADDR.

  3. Question 3 of 15 A cookie holds visits=3. What type is request.COOKIES['visits']?
    Show the answer: Question 3 of 15

    Answer: str, the text '3'

    Query strings, form fields, headers and cookies all arrive as text. A view converts and checks each value it uses, because anyone can send any value.

  4. Question 4 of 15 request.GET has no key sort. What does request.GET.getlist('sort') return?

    Choose one answer.

    Show the answer: Question 4 of 15

    Answer: An empty list

    For a key that may be missing, use get() with a default or getlist(). Indexing raises MultiValueDictKeyError, and if the view does not catch it the visitor gets a 500 error.

  5. Question 5 of 15 A view wants a link to the next page, so it sets request.GET['page'] = '3'. What happens?

    Choose one answer.

    Show the answer: Question 5 of 15

    Answer: AttributeError: This QueryDict instance is immutable

    Make a copy with request.GET.copy(), change the copy, and call urlencode() on it to build the new query string. The next lessons' querystring tag does the same in a template.

  6. Question 6 of 15 A JSON endpoint has just created a notice. Which status code says that best?

    Choose one answer.

    Show the answer: Question 6 of 15

    Answer: 201 Created

    Choose the most specific status: 201 for created, 204 for done with nothing to send, 404 for missing, 405 for a wrong method, 409 for a conflict and 422 for data that fails validation.

  7. Question 7 of 15 Which response makes the browser download the export as notices.csv instead of showing it?

    Choose one answer.

    Show the answer: Question 7 of 15

    Answer: FileResponse(data, as_attachment=True, filename='notices.csv')

    FileResponse streams a file and fills in Content-Type and Content-Disposition from the file name; as_attachment asks the browser to save it rather than show it.

  8. Question 8 of 15 Two staff members edit notice 7 at once. The second save would overwrite the first one's change, so the view refuses it. Which status fits?

    Choose one answer.

    Show the answer: Question 8 of 15

    Answer: 409 Conflict

    Status codes are part of the answer: they let a client react without reading the body. A conflict asks the client to reload and decide, a validation error asks it to fix the data.

  9. Question 9 of 15 What does redirect('notices:detail', pk=10) return?

    Choose one answer.

    Show the answer: Question 9 of 15

    Answer: An HttpResponseRedirect, 302, to /notices/10/

    redirect() accepts a route's name with its values, a model with get_absolute_url() or a path, and returns a 302 response by default, or a 301 with permanent=True.

  10. Question 10 of 15 The form view finds a missing title and calls render(request, 'notices/form.html', context, status=422). What does the browser get?

    Choose one answer.

    Show the answer: Question 10 of 15

    Answer: The form page with its error, and status 422

    render(request, template_name, context, status=…) is template loading, rendering and HttpResponse in one call. A failed form is shown again, with its errors and a status that says it failed; only a success redirects.

  11. Question 11 of 15 A view uses Notice.objects.get(pk=pk) without try or except. Visitors who guess /notices/70000/ get errors. What do they get, and what do you ship?

    Choose one answer.

    Show the answer: Question 11 of 15

    Answer: 500 errors, from Notice.DoesNotExist; ship get_object_or_404

    A missing row is the client's problem, a 404, not the server's, a 500. get_object_or_404 makes that one line, and keeps the error log for real failures.

  12. Question 12 of 15 What does @require_POST do to a view?

    Choose one answer.

    Show the answer: Question 12 of 15

    Answer: It answers every method but POST with 405, before the view runs

    require_GET, require_POST and require_http_methods([...]) answer the wrong method with 405 and an Allow header, so each view handles only the methods it was written for.

  13. Question 13 of 15 A view should serve its page to GET and nothing else, yet health checks send HEAD. What do require_GET and require_safe do with the HEAD?

    Choose one answer.

    Show the answer: Question 13 of 15

    Answer: require_GET answers it 405; require_safe lets it through, since it allows GET and HEAD

    A page that only reads can accept HEAD as well as GET. Django's documentation suggests require_safe over require_GET, because link checkers and similar tools send HEAD, which asks for the headers of a GET.

  14. Question 14 of 15 How should a view answer a POST that saved a notice successfully?

    Choose one answer.

    Show the answer: Question 14 of 15

    Answer: With a redirect to a page that a GET can fetch, such as the new notice

    After a successful POST, redirect. The browser fetches the next page with a GET, and refresh repeats only that GET. A failed POST shows the form again with its errors instead.

  15. Question 15 of 15 Nivas uses Post/Redirect/Get for maintenance payments, yet a resident still paid twice: they double-clicked Pay on a slow connection. Why did PRG not help?

    Choose one answer.

    Show the answer: Question 15 of 15

    Answer: Both POSTs left the browser before any redirect came back, so only the server can recognise the second one and refuse it

    Post/Redirect/Get stops the duplicate a refresh would cause. Duplicates from double clicks, retries and scripts need the server to recognise a request it has already handled, which the interview question below takes further.

Interview questions

Warm-up (fresher): For ?category=water&category=lift, why does request.GET['category'] give only 'lift', and how do you get both?

A query string may repeat a key, so request.GET is a QueryDict, which keeps every value of a key in order. Indexing it, like get(), returns the last value, 'lift', for code that expects one value per key. getlist('category') returns all of them, ['water', 'lift'], and an empty list when the key is missing. The same holds for request.POST, where checkboxes that share a name post several values. Read keys that may repeat with getlist(), and check each value against the ones you allow.

Two design questions of this module start from these views: a payment that a refresh or a double click sends twice, and a JSON endpoint written without an API framework.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to senior

    Residents pay their monthly maintenance through a Django form. Some refresh the confirmation page, see "Confirm form resubmission" and pay twice; others double-click Pay on a slow connection. Why does each happen, and how do you make a second payment for the same bill impossible?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A mobile app needs POST /api/notices/ to create a notice from a JSON body, and the team does not want an API framework yet. Describe the plain Django view: what it checks, in which order, and which status code it answers in each case.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A view gets one HttpRequest and returns one HttpResponse; the request carries the method, the path, the query string, headers in any case, cookies and the body, all as the client sent them, unconverted.
  • request.GET and request.POST are QueryDicts: indexing and get() give a key’s last value, getlist() all of them, and request.POST stays empty for a JSON body, which is in request.body.
  • Choose the response class for the body, HttpResponse, JsonResponse, FileResponse or a redirect, and the status code for what happened, such as 201, 404, 405, 409 or 422.
  • render(), redirect() and get_object_or_404() do in one line what most views repeat, and the last one turns a missing row into a 404 instead of a 500.
  • The method decorators answer methods a view does not handle with 405 and an Allow header; require_safe allows GET and HEAD.
  • After a successful POST, redirect: the browser’s last request is then a GET, and refreshing never sends the form again.

Terms in this lesson

HttpRequest
The object Django builds for each request and passes to the view as its first argument: the method, the path, GET, POST, the headers, COOKIES and the body, and later attributes that middleware adds, such as user.
Post/Redirect/Get
Answering a successful form POST with a redirect to a page fetched with GET, so that the browser's last request is the GET and refreshing the page never sends the form again.
QueryDict
Django's dictionary for a query string or form data, as in request.GET and request.POST: one key can hold several values, getlist() returns them all, and the copies a request gives a view cannot be changed.
status code
The three-digit number at the start of an HTTP response that says what happened, such as 200 OK, 201 Created, 302 Found for a redirect, 404 Not Found or 405 Method Not Allowed.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.