Filter concepts by levelShowing all levels.

Django · Section 6

Function-Based Views

Level
beginner
Read
24 min
Concepts
4

The response-building shortcuts every FBV reaches for (render(), redirect(), HttpResponse, JsonResponse), reading query parameters/POST data/files/headers/cookies off the request, branching on HTTP method by hand, and the session/authentication context every request already carries.

This section

What is true here

  1. render() builds a template response; redirect() builds a 302 to a URL, view name, or model; JsonResponse defaults to dict-only unless given safe=False.
  2. request.GET/request.POST are QueryDicts — a repeated key needs getlist(), not plain indexing, to get every value.
  3. request.POST never carries uploaded files — those arrive separately, through request.FILES.
  4. An FBV handles every HTTP method through one function — branch on request.method, or use @require_POST/@require_http_methods for an automatic 405.
  5. request.user is always a real user or AnonymousUser, never None — always check .is_authenticated, never plain truthiness.

What you will be able to do

  • Choose the right response helper for a given situation, including JsonResponse's safe= parameter
  • Read a multi-value query parameter correctly with getlist()
  • Restrict a view to specific HTTP methods, with a proper 405 on mismatch
  • Gate a view on authentication without risking an error on an anonymous visitor

Building responses

The shortcuts and classes every FBV uses to satisfy the "must return an HttpResponse" contract.

render(), redirect(), HttpResponse, JsonResponse

corebeginner

render(request, template, context) builds an HttpResponse from a template; redirect(to) builds a 302 to a URL, view name, or model; HttpResponse(content) wraps raw content directly; JsonResponse(data) serializes a dict to JSON with the right content type.

Think of it as

These four are just different factories for the one thing every view must produce — an HttpResponse. render() asks a template to do the work, redirect() asks the browser to go somewhere else instead, HttpResponse hands back exactly the bytes given, and JsonResponse is HttpResponse pre-configured for one very common case: a dict, serialized, with the right Content-Type already set.

python
from django.shortcuts import redirect

redirect("articles:detail", pk=5)      # by view name
redirect("/articles/5/")               # by literal URL
redirect(article_instance)             # by model, via get_absolute_url()

What we're doing: Return a JSON list from an API view, correctly opting out of JsonResponse's dict-only default.

articles/views.pypython
from django.http import JsonResponse

def article_titles(request):
    titles = list(Article.objects.values_list("title", flat=True))
    return JsonResponse(titles, safe=False)
5
safe=False is required here because titles is a list, not a dict — JsonResponse refuses to serialize anything but a dict unless told explicitly.

Why this works: JsonResponse defaults to safe=True as a security measure: serializing a top-level JSON array was historically exploitable in old browsers via a redefined Array constructor. Requiring safe=False to opt out makes serializing a non-dict a deliberate choice, not an accident.

Passing a list to JsonResponse without safe=False

Wrong

python
def article_titles(request):
    titles = list(Article.objects.values_list("title", flat=True))
    return JsonResponse(titles)  # raises

Better

python
def article_titles(request):
    titles = list(Article.objects.values_list("title", flat=True))
    return JsonResponse(titles, safe=False)

What you see: TypeError: In order to allow non-dict objects to be serialized set the safe parameter to False.

Why: JsonResponse's safe=True default only accepts a dict — Django raises immediately rather than silently serializing a list, forcing safe=False to be an explicit, deliberate choice at every call site that needs it.

Four factories for the one thing every view must return
render()
template → HttpResponse
HttpResponse()
raw content, any content_type
redirect()
302 to a URL, view name, or model
JsonResponse()
dict → JSON, safe=False for a list
  • render(): same page, HTML — template → HttpResponse
  • HttpResponse(): same page, between HTML and data — raw content, any content_type
  • redirect(): somewhere else / raw data, HTML — 302 to a URL, view name, or model
  • JsonResponse(): somewhere else / raw data, data — dict → JSON, safe=False for a list

Response helpers at a glance

Response helpers at a glance
HelperReturnsTypical use
render(request, tpl, ctx)HttpResponse, rendered templatea normal HTML page
redirect(to)HttpResponseRedirect (302)after a successful POST
redirect(to, permanent=True)HttpResponsePermanentRedirect (301)a URL that moved for good
HttpResponse(content)raw content, any content_typeplain text, a non-HTML body
JsonResponse(data)JSON-serialized dict, application/jsonan API endpoint

Together

python
from django.shortcuts import render, redirect
from django.http import JsonResponse

def article_list(request):
    return render(request, "articles/list.html", {"articles": Article.objects.all()})

def article_create(request):
    article = Article.objects.create(...)
    return redirect(article)  # uses article.get_absolute_url()

def article_api(request):
    return JsonResponse({"count": Article.objects.count()})

Remember: render() builds a template response, redirect() builds a 302 (to a URL, view name, or model), JsonResponse defaults to dict-only unless given safe=False.

See also: views · templates · reverse and reverse lazy

Advertisement

Reading the request

Query parameters, POST data, uploaded files, headers, and cookies — and the method that carried them.

Reading data off the request

corebeginner

request.GET and request.POST are both QueryDict objects — dict-like, but a repeated key keeps every value (getlist()), and indexing returns only the last one. request.FILES holds uploaded files separately; request.headers and request.COOKIES are both read like plain dicts.

Think of it as

Think of QueryDict as a dict built from a form that allowed the same field name twice — like a paper form with two "hobby" lines. request.GET["hobby"] hands you only the last one filled in, but request.GET.getlist("hobby") hands you both. request.POST never carries file data, no matter how it's accessed — files always arrive through the separate request.FILES, because they're encoded differently in the request body (multipart) than ordinary fields.

python
def upload(request):
    if request.method == "POST":
        f = request.FILES["document"]
        for chunk in f.chunks():
            ...

What we're doing: Read a multi-value query parameter correctly, and access an uploaded file alongside ordinary form fields on the same POST request.

search/views.pypython
def search(request):
    query = request.GET.get("q", "")
    categories = request.GET.getlist("category")   # ?category=books&category=films
    # categories == ["books", "films"], not just "films"
    return render(request, "search/results.html", {"query": query, "categories": categories})
3
getlist() is required here because a single .get("category") would silently return only "films", the last of two repeated query parameters.
4
The comment states explicitly what a reader would otherwise have to know from experience — QueryDict's repeated-key behavior is easy to miss on a first read.

Why this works: A URL like ?category=books&category=films is exactly how an HTML multi-select or repeated checkbox group encodes several selected values — QueryDict.getlist() is the method built specifically to read all of them back, instead of the ordinary dict-like access that would only surface the last.

Checking `if request.POST:` to detect a POST request

Wrong

python
def submit(request):
    if request.POST:   # empty dict is falsy — this can silently be False
        process(request.POST)
    else:
        return render(request, "form.html")

Better

python
def submit(request):
    if request.method == "POST":
        process(request.POST)
    else:
        return render(request, "form.html")

What you see: A legitimate POST request with an empty body (or a form with no named fields, or fields that all failed to parse) is treated as if it were a GET — the form re-renders instead of processing the submission.

Why: request.POST is a QueryDict, and an empty QueryDict is falsy in a boolean context — exactly like an empty dict or list. request.method is the value Django actually sets from the HTTP request line itself, so it reflects the real method regardless of what ended up in the body.

Where each piece of request data lives

page = request.GET.get("page", "1") selected = request.GET.getlist("tag") avatar = request.FILES.get("avatar") ua = request.headers.get("User-Agent")

request.GET.get("page"

request.GET — QueryDict — indexing returns the LAST value for a repeated key

request.GET.getlist("tag")

getlist() — returns every value for a repeated key

request.FILES.get("avatar")

request.FILES — uploaded files — never in request.POST

request.headers.get("User-Agent")

request.headers — case-insensitive dict-like

  • Whole: page = request.GET.get("page", "1") selected = request.GET.getlist("tag") avatar = request.FILES.get("avatar") ua = request.headers.get("User-Agent")
  • request.GET.get("page" — request.GET: QueryDict — indexing returns the LAST value for a repeated key
  • request.GET.getlist("tag") — getlist(): returns every value for a repeated key
  • request.FILES.get("avatar") — request.FILES: uploaded files — never in request.POST
  • request.headers.get("User-Agent") — request.headers: case-insensitive dict-like

Reading request data

Reading request data
AttributeTypeHolds
request.GETQueryDict (immutable)query string parameters
request.POSTQueryDict (immutable)form-encoded body fields — never files
request.FILESdict-like of UploadedFileuploaded files from a multipart form
request.headerscase-insensitive dict-likeHTTP request headers
request.COOKIESplain dictcookies sent with the request

Together

python
page = request.GET.get("page", "1")
selected = request.GET.getlist("tag")   # ?tag=a&tag=b&tag=c -> ["a", "b", "c"]
avatar = request.FILES.get("avatar")
ua = request.headers.get("User-Agent")

Remember: GET/POST are QueryDicts — indexing returns the last of a repeated key, getlist() returns all; POST never carries files (use FILES); check request.method, not `if request.POST`, to detect a POST.

See also: views · forms · http methods

Handling HTTP methods in an FBV

standardbeginner

A function-based view receives every HTTP method (GET, POST, PUT, DELETE...) through the same function and must branch on request.method itself — Django provides require_GET/require_POST/require_http_methods decorators to reject the wrong method with a proper 405 instead of writing that check by hand.

Think of it as

An FBV is one door that every kind of visitor walks through — a GET, a POST, a DELETE, all arrive at the same function. Nothing routes them to different code automatically; request.method is the only signal, and it is the view's own job to branch on it (or use a decorator that does the rejecting up front, before the view body even runs).

python
from django.views.decorators.http import require_POST

@require_POST
def delete_article(request, pk):
    Article.objects.filter(pk=pk).delete()
    return redirect("articles:list")

What we're doing: Branch on request.method inside one view that both displays a form (GET) and processes it (POST).

articles/views.pypython
def article_create(request):
    if request.method == "POST":
        form = ArticleForm(request.POST)
        if form.is_valid():
            article = form.save()
            return redirect(article)
    else:
        form = ArticleForm()
    return render(request, "articles/form.html", {"form": form})
4
request.method == "POST" is the branch that decides whether this request is a submission to process or a fresh page to display — nothing else about the request tells the view that automatically.

Why this works: One FBV commonly handles both showing a blank form (GET) and processing its submission (POST), because the URL and the logical "thing" the view represents are the same either way — request.method is what distinguishes the two without needing two separate URL patterns.

Leaving a state-changing view reachable via GET

Wrong

python
def delete_article(request, pk):
    Article.objects.filter(pk=pk).delete()   # runs on ANY method, including GET
    return redirect("articles:list")

Better

python
from django.views.decorators.http import require_POST

@require_POST
def delete_article(request, pk):
    Article.objects.filter(pk=pk).delete()
    return redirect("articles:list")

What you see: A search engine crawler, a browser prefetch, or a link preview bot following a plain <a href="/articles/5/delete/"> link triggers the delete — a state change from a simple GET.

Why: GET requests are expected, by HTTP's own semantics and by every tool that crawls or prefetches links, to be safe — they should never change server state. A view with no method check treats every method identically, so anything that can trigger a GET (including automated tools with no intent to delete anything) can trigger the delete.

Method-restriction decorators

Method-restriction decorators
DecoratorAcceptsOn mismatch
@require_GETGET only405, with Allow: GET
@require_POSTPOST only405, with Allow: POST
@require_safeGET and HEAD405
@require_http_methods([...])exactly the listed methods405, with Allow header listing them

Together

python
from django.views.decorators.http import require_http_methods

@require_http_methods(["GET", "POST"])
def article_form(request):
    if request.method == "POST":
        ...
    return render(request, "form.html")

Remember: An FBV receives every HTTP method through the same function — branch on request.method explicitly, or use @require_POST/@require_http_methods to reject the wrong method with a proper 405.

See also: request data · views · forms

Advertisement

Session and authentication

The per-visitor state and identity every request already carries, set by middleware before the view runs.

Session and authentication context in a view

standardbeginner

request.session is a dict-like, per-visitor store set by SessionMiddleware; request.user is the current user (or AnonymousUser) set by AuthenticationMiddleware. Both are only present because their middleware ran first — remove either from MIDDLEWARE and the attribute disappears.

Think of it as

request.session and request.user are both handed to the view already filled in, like a form that arrives pre-stamped by two earlier clerks (SessionMiddleware, AuthenticationMiddleware) before it reaches the view's desk. The view never has to look anything up itself — it just reads what's already there, which is also why removing either middleware makes the corresponding attribute vanish rather than error in some other way.

python
request.session["cart_id"] = cart.id
cart_id = request.session.get("cart_id")
del request.session["cart_id"]

What we're doing: Track a per-visitor counter in the session, and gate a view on authentication without ever risking an AttributeError on an anonymous visitor.

accounts/views.pypython
def profile(request):
    if not request.user.is_authenticated:
        return redirect("login")
    visits = request.session.get("visit_count", 0)
    request.session["visit_count"] = visits + 1
    return render(request, "accounts/profile.html", {"user": request.user, "visits": visits})
2
request.user.is_authenticated works safely even for a logged-out visitor — AnonymousUser implements the same interface and always answers False, never raising.
4
.get("visit_count", 0) handles the first-ever visit, where the session has no such key yet, without a KeyError.

Why this works: AnonymousUser exists specifically so views never need a None check before accessing request.user — every visitor, logged in or not, gets an object implementing the same interface (is_authenticated, is_staff, etc.), which is what lets `if not request.user.is_authenticated:` be written safely without first confirming request.user is not None.

Checking `if request.user:` instead of `if request.user.is_authenticated:`

Wrong

python
def dashboard(request):
    if request.user:   # an AnonymousUser instance is still truthy
        return render(request, "dashboard.html")
    return redirect("login")

Better

python
def dashboard(request):
    if request.user.is_authenticated:
        return render(request, "dashboard.html")
    return redirect("login")

What you see: Anonymous visitors reach the dashboard view meant for logged-in users only — the authentication check never actually rejects anyone.

Why: request.user is never None — it is always either a real user instance or an AnonymousUser instance, and both are truthy Python objects. `if request.user:` is always True regardless of login state; is_authenticated is the attribute that actually distinguishes the two.

request.session vs request.user

request.session vs request.user
AttributeSet byAbsent visitor gets
request.sessionSessionMiddlewarea fresh, empty session — never missing entirely
request.userAuthenticationMiddlewarean AnonymousUser instance, never None

Together

python
def dashboard(request):
    if not request.user.is_authenticated:
        return redirect("login")
    visits = request.session.get("visit_count", 0)
    request.session["visit_count"] = visits + 1
    return render(request, "dashboard.html", {"visits": visits})

Remember: request.session is a dict-like per-visitor store; request.user is always a real user or AnonymousUser, never None — check .is_authenticated, never `if request.user:`.

See also: sessions · authentication · middleware

Advertisement