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.
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.
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.
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.
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.
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.
<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 <Anna></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.
<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.
<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_http_methods(['GET', 'POST'])- allows the listed methodsrequire_GETandrequire_POST- allow one methodrequire_safe- allowsGETandHEAD
@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.
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
List all Django tutorials.