ZetCode

Django views

last modified November 13, 2026 Python 3.15

In this article we write Django function-based views. We use the render(), redirect(), and get_object_or_404() shortcuts, restrict the HTTP methods of a view with decorators, and see how the CSRF protection treats POST requests. The examples use Django 6.1 and Python 3.15.

Django views

A view is a callable that takes an HttpRequest and returns an HttpResponse. In the simplest form, a view is a Python function; such views are called function-based views. The other form, class-based views, wraps the same idea in a class. A view can return a page, a redirect, a 404 error, a file, or JSON. It can also raise an exception, which Django turns into an error response.

The first argument of a view is always the request. The remaining arguments are the values captured by the URL pattern (see Django URL routing). The request and response objects are described in Django HttpRequest and Django HttpResponse.

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, and start the app notes.

$ mkdir views
$ cd views
$ uv init --bare
$ uv add django
$ uv run django-admin startproject config .
$ uv run python manage.py startapp notes

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',
    'notes',
]

Every listing in this article is a file of the application, shown with a solid blue border and the label Project file.

We build a tiny notes application. The views need some data, so we define a model with a title, a text, and a creation time. Models are covered in a later article; here the model only provides the data.

notes/models.py
from django.db import models


class Note(models.Model):
    title = models.CharField(max_length=100)
    text = models.TextField(blank=True)
    created = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return self.title

The makemigrations command creates the migration for the model and migrate creates the table in the SQLite database. We also add two notes with a one-line shell command.

$ uv run python manage.py makemigrations notes
Migrations for 'notes':
  notes/migrations/0001_initial.py
    + Create model Note
$ uv run python manage.py migrate notes
Operations to perform:
  Apply all migrations: notes
Running migrations:
  Applying notes.0001_initial... OK
$ uv run python manage.py shell --no-imports -c "from notes.models import Note; Note.objects.create(title='Buy milk', text='Two litres'); Note.objects.create(title='Call <Anna>', text='About the trip')"

URL configuration

The app maps six routes. The names are in the notes namespace, which the views and templates use to build URLs.

notes/urls.py
from django.urls import path

from . import views

app_name = 'notes'

urlpatterns = [
    path('', views.index, name='index'),
    path('notes/new/', views.create, name='create'),
    path('notes/<int:pk>/', views.detail, name='detail'),
    path('notes/<int:pk>/delete/', views.delete, name='delete'),
    path('ping/', views.ping, name='ping'),
    path('webhook/', views.webhook, name='webhook'),
]

The project's urls.py includes the app's URLconf at the site root. The list view lives at /, the detail at /notes/1/, and so on.

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

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

The empty prefix of include() mounts the URLs of the app directly under the root of the site; the project has no other routes.

The views module

The six views of the app live in one module. We show it in full here; the next sections take the views one by one.

notes/views.py
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods, require_POST, require_safe

from .models import Note


def index(request):
    notes = Note.objects.order_by('id')
    return render(request, 'notes/index.html', {'notes': notes})


def detail(request, pk):
    note = get_object_or_404(Note, pk=pk)
    return render(request, 'notes/detail.html', {'note': note})


@require_http_methods(['GET', 'POST'])
def create(request):
    if request.method == 'POST':
        title = request.POST.get('title', '').strip()
        text = request.POST.get('text', '').strip()
        if not title:
            context = {'error': 'The title is required.', 'text': text}
            return render(request, 'notes/create.html', context, status=400)
        note = Note.objects.create(title=title, text=text)
        return redirect('notes:detail', pk=note.pk)
    return render(request, 'notes/create.html')


@require_POST
def delete(request, pk):
    note = get_object_or_404(Note, pk=pk)
    note.delete()
    return redirect('notes:index')


@require_safe
def ping(request):
    return HttpResponse('pong\n', content_type='text/plain')


@csrf_exempt
@require_POST
def webhook(request):
    size = len(request.body)
    return HttpResponse(f'Received {size} bytes\n', content_type='text/plain')

The imports bring in the response class, the shortcut functions, and the decorators that restrict the HTTP methods or exempt a view from the CSRF check. Each of them is explained in the section where it is used.

A view with render()

The render() shortcut combines three steps: it loads a template, renders it with a context dictionary, and wraps the result in an HttpResponse. Its first argument is the request, which makes request data available to context processors. The view and its template follow.

def index(request):
    notes = Note.objects.order_by('id')
    return render(request, 'notes/index.html', {'notes': notes})

The view queries the notes ordered by id and passes the queryset to the template as notes. The template is below.

notes/templates/notes/index.html
<h1>Notes</h1>
<ul>
{% for note in notes %}
  <li><a href="{% url 'notes:detail' note.pk %}">{{ note.title }}</a></li>
{% empty %}
  <li>No notes yet.</li>
{% endfor %}
</ul>
<p><a href="{% url 'notes:create' %}">New note</a></p>

The template loops over the notes with {% for %}, falls back to the {% empty %} branch when there is nothing to show, and builds the links with the {% url %} tag. The second note has the title Call <Anna>; the template engine escapes the angle brackets, so they are displayed as text instead of being treated as a tag.

$ curl -s http://127.0.0.1:8000/
<h1>Notes</h1>
<ul>

  <li><a href="/notes/1/">Buy milk</a></li>

  <li><a href="/notes/2/">Call &lt;Anna&gt;</a></li>

</ul>
<p><a href="/notes/new/">New note</a></p>

get_object_or_404()

A detail view must handle a missing object. The get_object_or_404() shortcut calls the get() method of a model manager or queryset and raises Http404 when the object does not exist. Django catches that exception and produces the 404 response, so the view does not need an if statement. The shortcut accepts a model, a manager, or a queryset as the first argument and lookup arguments after it.

def detail(request, pk):
    note = get_object_or_404(Note, pk=pk)
    return render(request, 'notes/detail.html', {'note': note})

The detail template prints the note and a small form that sends a POST request to the delete view.

notes/templates/notes/detail.html
<h1>{{ note.title }}</h1>
<p>{{ note.text }}</p>
<form method="post" action="{% url 'notes:delete' note.pk %}">
  {% csrf_token %}
  <button type="submit">Delete</button>
</form>

The form contains the {% csrf_token %} tag, which inserts a hidden field with the CSRF token; we explain it in the section about CSRF protection. The first request below finds the note and renders the template; we cut the token value from the output. The second asks for a note that does not exist and gets status 404. The -w option of curl prints the status code and -o /dev/null discards the body.

$ curl -s http://127.0.0.1:8000/notes/1/
<h1>Buy milk</h1>
<p>Two litres</p>
<form method="post" action="/notes/1/delete/">
  <input type="hidden" name="csrfmiddlewaretoken" value="...">
  <button type="submit">Delete</button>
</form>
$ curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8000/notes/99/
404

Handling GET and POST in one view

A form page is usually served by one URL: a GET request shows the empty form and a POST request processes the submitted data. The view dispatches on request.method. The submitted fields are available in the request.POST dictionary-like object.

@require_http_methods(['GET', 'POST'])
def create(request):
    if request.method == 'POST':
        title = request.POST.get('title', '').strip()
        text = request.POST.get('text', '').strip()
        if not title:
            context = {'error': 'The title is required.', 'text': text}
            return render(request, 'notes/create.html', context, status=400)
        note = Note.objects.create(title=title, text=text)
        return redirect('notes:detail', pk=note.pk)
    return render(request, 'notes/create.html')

When the title is empty, the view renders the form again with an error message and the status code 400, so that the client knows the request was rejected. Otherwise it creates the note and redirects to its detail page. The form data is validated by hand here; the forms framework does it in a more structured way. The template keeps the text the user typed.

notes/templates/notes/create.html
<h1>New note</h1>
{% if error %}<p class="error">{{ error }}</p>{% endif %}
<form method="post">
  {% csrf_token %}
  <input name="title" placeholder="Title">
  <textarea name="text">{{ text }}</textarea>
  <button type="submit">Save</button>
</form>

The GET request below returns the form. The csrfmiddlewaretoken hidden field generated by the tag contains a per-request value, so we cut it from the output; the rest of the page is shown as is.

$ curl -s http://127.0.0.1:8000/notes/new/
<h1>New note</h1>

<form method="post">
  <input type="hidden" name="csrfmiddlewaretoken" value="...">
  <input name="title" placeholder="Title">
  <textarea name="text"></textarea>
  <button type="submit">Save</button>
</form>

redirect()

The redirect() shortcut returns an HttpResponseRedirect (status 302) or, with permanent=True, an HttpResponsePermanentRedirect (status 301). Its argument can be a model object (its get_absolute_url() is used), a view or URL name with arguments, as in redirect('notes:detail', pk=note.pk), or a plain URL. Redirecting after a successful POST is the Post/Redirect/Get pattern: the browser then loads the result with a GET, so reloading the page does not submit the form twice. The tests at the end show the redirect in action.

Restricting HTTP methods

The decorators from django.views.decorators.http limit the HTTP methods that a view accepts. For any other method Django returns a response with status 405 (Method Not Allowed) and an Allow header. The available decorators are the following.

@require_POST
def delete(request, pk):
    note = get_object_or_404(Note, pk=pk)
    note.delete()
    return redirect('notes:index')


@require_safe
def ping(request):
    return HttpResponse('pong\n', content_type='text/plain')

Operations that change data, such as deleting a note, must not be done on a GET request: a link prefetcher or a crawler would trigger them. The delete() view accepts only POST. The ping() view is read-only and accepts GET and HEAD. The requests below show that a GET of the delete URL is refused and that ping answers; the -I option of curl sends a HEAD request.

$ curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8000/notes/1/delete/
405
$ curl -s http://127.0.0.1:8000/ping/
pong
$ curl -s -I http://127.0.0.1:8000/ping/ | head -1
HTTP/1.1 200 OK

CSRF protection

The CsrfViewMiddleware, enabled in the default settings, rejects POST, PUT, PATCH, and DELETE requests that do not carry a valid CSRF token. A cross-site request forgery tricks the browser of a logged-in user into sending a request to our site from a malicious page; the attacker cannot read the token, so the forged request fails. A form includes the token with the {% csrf_token %} tag.

A command-line client has no token, so the POST request to the delete URL is refused with status 403 before the view is called. The webhook() view is decorated with csrf_exempt, which switches the check off for it; this is meant for endpoints called by other servers, which cannot have a token, and such endpoints must authenticate their callers in another way, for instance with a signature.

@csrf_exempt
@require_POST
def webhook(request):
    size = len(request.body)
    return HttpResponse(f'Received {size} bytes\n', content_type='text/plain')

The decorators are applied from the bottom up, so require_POST wraps the view first and csrf_exempt wraps the result. The -d option of curl sends a POST request with the given body.

$ curl -s -o /dev/null -w "%{http_code}\n" -X POST http://127.0.0.1:8000/notes/1/delete/
403
$ curl -s -d "hello" http://127.0.0.1:8000/webhook/
Received 5 bytes

Testing views

Django's test client calls the views without a running server. The tests below cover the behavior described in this article: a missing note gives 404, a valid POST redirects, an invalid one gives 400, the delete view refuses GET, and the CSRF check applies to the delete view but not to the webhook. The test client does not check CSRF tokens by default; the Client(enforce_csrf_checks=True) instance turns the check on.

notes/tests.py
from django.test import Client, TestCase
from django.urls import reverse

from .models import Note


class NoteViewTests(TestCase):

    def setUp(self):
        self.note = Note.objects.create(title='First', text='Hello')

    def test_index_lists_notes(self):
        response = self.client.get(reverse('notes:index'))
        self.assertContains(response, 'First')

    def test_detail_missing_note_is_404(self):
        response = self.client.get(reverse('notes:detail', args=[999]))
        self.assertEqual(response.status_code, 404)

    def test_create_redirects_to_detail(self):
        response = self.client.post(reverse('notes:create'), {'title': 'Second', 'text': 'x'})
        note = Note.objects.get(title='Second')
        self.assertRedirects(response, reverse('notes:detail', args=[note.pk]))

    def test_create_without_title_is_400(self):
        response = self.client.post(reverse('notes:create'), {'title': ' '})
        self.assertContains(response, 'The title is required.', status_code=400)

    def test_delete_requires_post(self):
        response = self.client.get(reverse('notes:delete', args=[self.note.pk]))
        self.assertEqual(response.status_code, 405)
        self.assertTrue(Note.objects.filter(pk=self.note.pk).exists())

    def test_delete_removes_note(self):
        response = self.client.post(reverse('notes:delete', args=[self.note.pk]))
        self.assertRedirects(response, reverse('notes:index'))
        self.assertFalse(Note.objects.exists())

    def test_ping_allows_safe_methods_only(self):
        self.assertEqual(self.client.head(reverse('notes:ping')).status_code, 200)
        self.assertEqual(self.client.post(reverse('notes:ping')).status_code, 405)

    def test_csrf_is_enforced_except_for_webhook(self):
        client = Client(enforce_csrf_checks=True)
        self.assertEqual(client.post(reverse('notes:delete', args=[self.note.pk])).status_code, 403)
        self.assertEqual(client.post(reverse('notes:webhook'), 'data', content_type='text/plain').status_code, 200)

The assertRedirects() method checks the redirect status, the target URL, and that the target responds with 200. The assertContains() method with status_code=400 checks both the status and the text. Each test runs in a transaction that is rolled back, so tests do not affect each other or our database.

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

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

Source

Django documentation: Writing views

Django documentation: Django shortcut functions

Django documentation: How to use Django's CSRF protection

In this article we have written function-based views that render templates, look up objects, handle GET and POST in one place, redirect, restrict HTTP methods, and use CSRF protection. The next article, Django class-based views, shows how the same patterns are done with classes; see also Django URL routing.

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.