Getting Started
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Django 6.1 (6.1.1, released 2026-09-02) and Python 3.12 to 3.14, checked on 2026-10-03; the code was executed on Python 3.13 with Django 6.1.1 and SQLite. Django 5.2 LTS (supported until April 2028) differs from 6.1 in a few places that are called out where they matter. This is the first hands-on page of the section: it takes you from an empty directory to a running, tested, admin-enabled site and then walks through the official tutorial, part by part, so that you know what every file, command and line does and where it sits in the request/response cycle. The running scenario of the section, the Bookshelf (a catalogue of books, authors and reviews), grows out of the same moves; the tutorial’s polls app is used here because every Django tutorial, book and answer on the web starts from it.
Install Django
Django is a pure Python package with two small hard dependencies (asgiref and sqlparse). Everything else (a database
driver, an image library, Redis) is optional and only needed for the feature that uses it. The installation guide lists
three sources: an official release from PyPI (the right choice for nearly everyone), the package your operating system
distributes (usually old), and the development branch (only for people who want to help test it).
The Python version matrix
Each Django release supports a window of Python versions, and only the latest micro release of each Python series is officially supported:
| Django | Python versions |
|---|---|
4.2 LTS |
3.8, 3.9, 3.10, 3.11, 3.12 (added in 4.2.8). Security support ended in April 2026. |
5.0 |
3.10, 3.11, 3.12 |
5.1 |
3.10, 3.11, 3.12, 3.13 (added in 5.1.3) |
5.2 LTS |
3.10, 3.11, 3.12, 3.13, 3.14 (added in 5.2.8). Extended support until April 2028. |
6.0 |
3.12, 3.13, 3.14 |
6.1 |
3.12, 3.13, 3.14. Mainstream support until April 2027, extended support until December 2027. |
The practical rule is that Django 6.0 and later need Python 3.12 or newer, so an old system Python cannot install them. This is
easy to trip over: with the Python 3.9 that ships with some operating systems, pip looks at the package index, sees that
none of the newer releases declares support for its interpreter and fails with a message that looks like a typo in the
package name:
$ python3.9 -m pip install "Django>=6.1,<6.2"
ERROR: Could not find a version that satisfies the requirement Django<6.2,>=6.1 (from versions: 1.1.3, ..., 4.2.30)
ERROR: No matching distribution found for Django<6.2,>=6.1
Source: FAQ: Installation
The list of versions in that error stops at 4.2.30, which is the newest release whose metadata accepts Python 3.9 (the output above was captured on 2026-10-03). The fix is a newer interpreter, not a different command. If you must stay on an older Python for a while, pin the Django series it supports (Django 4.2 for Python 3.9) and read the matching documentation version through the version switcher on docs.djangoproject.com. The FAQ also gives the rule behind the table: Django supports a Python version up to and including the first LTS release whose security support ends after that Python version’s own security support ends. Python 3.10 and 3.11 were last supported by the Django 5.2.x series.
Create a project directory and a virtual environment
Give every project its own virtual environment, so its packages never collide with another project’s or with the system.
The tool-agnostic mechanics (what a virtual environment is, requirements.txt, lock files) are covered in the Python
Reference, in Virtual Environments & Packaging,
and are not repeated here. Two routes work equally well.
With uv, a fast project manager that creates the environment, installs Django and records exact versions in uv.lock:
uv init bookshelf --bare --python 3.13
cd bookshelf
uv add "django>=6.1,<6.2"
uv run python -m django --version
Source: Quick install guide
uv init --bare writes only a pyproject.toml. uv add creates .venv (you never activate it), adds the dependency to
pyproject.toml and locks the resolved versions. This was run on 2026-10-03 and installed asgiref==3.12.1,
django==6.1.1 and sqlparse==0.6.0; uv run python -m django --version printed 6.1.1. Prefix every later command
with uv run (for example uv run python manage.py runserver) or activate .venv yourself.
With the standard library and pip:
mkdir bookshelf && cd bookshelf
python3.13 -m venv .venv
source .venv/bin/activate # Windows PowerShell: .venv\Scripts\Activate.ps1
python -m pip install "Django>=6.1,<6.2"
python -m django --version
pip freeze # asgiref, Django, sqlparse
Source: How to install Django
Quote the requirement: without quotes the shell treats > and < as redirections. Pinning the series (>=6.1,<6.2)
means you receive security and bug-fix releases (6.1.2 and later) without a surprise jump to the next feature release.
After the install, django-admin is on the path of the environment, and python -m django is an equivalent spelling
that always uses the interpreter you are running, which is handy when two environments are installed.
Verify from Python itself, as the installation guide does:
>>> import django
>>> print(django.get_version())
6.1.1
>>> django.VERSION
(6, 1, 1, 'final', 0)
Source: Quick install guide
Databases and the web server you do not need yet
Django ships with a development server and uses SQLite, which is part of Python, so nothing else has to be installed for
this page. For production the installation guide recommends PostgreSQL (officially supported: PostgreSQL, MariaDB, MySQL,
Oracle and SQLite). Django 6.1 requires PostgreSQL 15 or newer, MySQL 8.4 or newer, MariaDB 10.11 or newer and SQLite
3.37 or newer, and a PostgreSQL project installs psycopg (psycopg 3 has been supported since Django 4.2). Production
servers (Gunicorn, Uvicorn, Daphne, Granian) are the subject of the deployment pages; nothing on this page needs them.
Windows users can follow the official How to install Django on Windows
for the interpreter and path setup.
Source: How to install Django, Databases
Create a Project With startproject
A project is the configuration of one Django site: settings, the root URL table and the entry points for web servers. It is generated, not written by hand:
django-admin startproject mysite djangotutorial
cd djangotutorial
The signature is django-admin startproject name [directory]. With both arguments (as above) the directory
djangotutorial is the project directory and mysite is the Python package inside it. With only a name, both the
directory and the package are called mysite, which produces the familiar mysite/mysite nesting. With . as the
directory, the project is generated in the current directory, which is the usual choice inside an already created repository:
uv run django-admin startproject config .
Source: django-admin and manage.py
Since Django 6.0 startproject and startapp also create the destination directory if it does not exist (earlier releases
failed when the directory was missing), so django-admin startproject mysite new/dir/here works in one step; this was
confirmed on 6.1.1. The name must be a valid Python identifier and must not shadow a Python or Django module: django
would conflict with Django itself and test with a standard library package.
The generated files
djangotutorial/
manage.py
mysite/
__init__.py
settings.py
urls.py
asgi.py
wsgi.py
| File | What it is for |
|---|---|
|
A thin command-line wrapper around |
|
The Python package of the project; its name is what you import ( |
|
An empty file that marks the directory as a package. |
|
Module-level variables that are the project’s configuration: |
|
The root URLconf, the "table of contents" of the site. |
|
The entry point ( |
|
The entry point ( |
Two things in the generated settings.py of Django 6.1.1 are worth knowing before you edit it. First, the file contains no
DEFAULT_AUTO_FIELD: that setting was written into generated projects from Django 3.2, but since Django 6.0 it defaults
to django.db.models.BigAutoField and the lines were removed from the project and app templates. Second, a MAILERS
setting with the console email backend is generated (new in 6.1; it will replace EMAIL_BACKEND and the EMAIL_*
settings in Django 2028, see the section on recent changes below):
# mysite/settings.py (excerpt, generated by Django 6.1.1)
DEBUG = True
ALLOWED_HOSTS = []
MAILERS = {
"default": {
"BACKEND": "django.core.mail.backends.console.EmailBackend",
},
}
Source: Django 6.1 release notes
SECRET_KEY is generated per project with the prefix django-insecure-. It is acceptable for local experiments and must be
replaced before deployment; the deployment check below shows exactly how Django complains. Settings are covered in depth on
the Settings & Configuration page of this section.
The Development Server
python manage.py runserver
Source: django-admin runserver
Watching for file changes with StatReloader
Performing system checks...
System check identified no issues (0 silenced).
You have 18 unapplied migration(s). Your project may not work properly until you apply the migrations for app(s): admin, auth, contenttypes, sessions.
Run 'python manage.py migrate' to apply them.
October 02, 2026 - 22:57:32
Django version 6.1.1, using settings 'mysite.settings'
Starting WSGI development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.
WARNING: This is a development server. Do not use it in a production setting. Use a production WSGI or ASGI server instead.
For more information on production servers see: https://docs.djangoproject.com/en/6.1/howto/deployment/
Opening http://127.0.0.1:8000/ shows the "The install worked successfully! Congratulations!" page (the page title was
confirmed on 6.1.1). That page is only served while DEBUG = True and the URLconf has no routes of its own; once you
add routes, / becomes a 404 until you map it. The banner above is the real output of Django 6.1.1 on an unmigrated project (the line
"Starting WSGI development server" names the server type, and the migration warning disappears after migrate); the
tutorial’s own sample output differs in small details, so treat any banner as illustrative.
runserver options
| Option | Effect |
|---|---|
|
A port ( |
|
Disable the autoreloader; code changes then require a manual restart. |
|
Serve one request at a time. The server is multithreaded by default. |
|
Use IPv6; the default address becomes |
|
Do not automatically serve static files at |
|
Serve static files even when |
|
Do not run the system checks at startup and on each reload. |
|
Options shared by every management command. |
Source: django-admin runserver
The reloader watches Python files and restarts the process on change (it prints Watching for file changes with StatReloader).
If Linux or macOS has both pywatchman and the Watchman service installed, Django switches to kernel notifications instead
of polling file timestamps once per second; the environment variable DJANGO_WATCHMAN_TIMEOUT raises the default five-second
client timeout. Adding a new file does not always trigger a restart, so restart by hand when a new module or template
directory is not picked up. Each request and response is logged by the django.server logger, and the warning banner can be
silenced with DJANGO_RUNSERVER_HIDE_WARNING=true.
Where the development server sits in the request cycle: it is a small multithreaded WSGI server. It builds a WSGI environ
for each HTTP request and calls the object named by the WSGI_APPLICATION setting (mysite.wsgi.application), exactly like
Gunicorn would, so everything described under "The request cycle" below runs identically. It has not been security audited
and is not meant for production. For asynchronous code it still works: an async def view runs under it (Django
adapts it to the synchronous request path), but only an ASGI server runs async views natively. This was checked with
a view added to the polls app below: it returned {"questions": 1} under runserver and under
python -m uvicorn mysite.asgi:application.
The Request Cycle in One Picture
Everything you will write in this page plugs into one path. A request enters through the WSGI or ASGI entry point
(wsgi.py or asgi.py), passes down through the middleware listed in MIDDLEWARE, is routed by the root URLconf named by
ROOT_URLCONF, reaches a view, and the response travels back up through the same middleware. The view reads or writes
models (the ORM) and renders a template. That is the whole loop, and it is the reason a typical change touches the same
four places in the same order: the model (data), the view (logic), the URLconf (address) and the template (presentation).
change a model"] models --> mk["makemigrations
write a migration file"] mk --> mig["migrate
apply it to the database"] mig --> view["views.py
write or change a view"] view --> url["urls.py
route a URL to the view"] url --> tpl["templates/
render the page"] tpl --> run["runserver
autoreload and system checks"] run --> browse["Browser or curl"] browse --> tests["manage.py test
automated tests"] tests -->|"fail: fix"| start tests -->|"pass: next feature"| start run -.->|"check errors"| start
The diagram is the development loop used on every following page. The dashed edge is the system check framework: both
runserver and manage.py check inspect settings, models, URLs and templates for common mistakes before a request is
served, which turns many typos into a readable error at startup.
Create an App With startapp
A project is a collection of configuration and apps. An app is a Python package that does one thing (polls, a catalogue,
a blog); the same app can be used in several projects and a project usually has several apps. Apps live anywhere on the
Python path; the tutorial puts them next to manage.py.
python manage.py startapp polls
polls/
__init__.py
admin.py
apps.py
migrations/
__init__.py
models.py
tests.py
views.py
| File | Purpose |
|---|---|
|
The app’s data model; every model class becomes a database table. |
|
The view functions and classes. |
|
Registers models with the admin site. |
|
The app’s |
|
Migration files derived from |
|
The place to start writing tests; replace it with a |
There is no urls.py in the generated app. By convention you create polls/urls.py yourself (next section); an app that
carries its own URLconf can be mounted anywhere with include(). There is also no templates/ or static/ directory yet;
you create them as needed.
startapp accepts the same destination and template options as startproject: --template (a directory, an archive or
a URL of a custom template), --extension/-e (which file extensions are rendered by the template engine, default py),
--name/-n (extra file names to render) and --exclude/-x (directories to skip, in addition to .git and
pycache). Template files ending in .py-tpl are renamed to .py so packaging tools do not try to byte-compile them.
The documentation warns that custom templates are trusted code: they become part of your project, so inspect any
third-party template before using it (the same applies to startproject --template).
A pitfall confirmed on 6.1.1: django-admin startapp hello apps/hello creates the files in apps/hello but writes
name = 'hello' into apps.py. If the app lives in a package, edit the generated name to its real dotted path
(apps.hello) and use that path in INSTALLED_APPS, or Django will not find the app. App layout, AppConfig and ready()
are covered on the Project Structure & Apps page.
Register the app
Add the app to INSTALLED_APPS in mysite/settings.py. The tutorial uses the dotted path of the configuration class:
INSTALLED_APPS = [
"polls.apps.PollsConfig",
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
]
Since Django 3.2 the shorter entry "polls" is equivalent when polls/apps.py defines a single AppConfig subclass
(automatic AppConfig discovery); this was verified by running the whole project below with "polls" instead: check is clean
and all ten tests pass. Books written for Django 1.x and 2.x register apps as "polls" and add
default_app_config = "polls.apps.PollsConfig" to init.py; that variable was deprecated in 3.2 and removed in 4.1, so do not copy it.
The First View, URL, Template and Model Loop
The tutorial builds one vertical slice at a time. Every snippet below was assembled into a single project, checked with
python manage.py check, migrated, covered by a ten-test suite (python manage.py test) and exercised over HTTP with curl
against runserver on 2026-10-03.
Step 1: a view and a URL (tutorial part 1)
A view is a callable that receives an HttpRequest and returns an HttpResponse or raises an exception such as Http404.
That is the only contract.
# polls/views.py
from django.http import HttpResponse
def index(request):
return HttpResponse("Hello, world. You're at the polls index.")
To reach it, give the app a URLconf (a list named urlpatterns) and mount that in the project’s URLconf:
# polls/urls.py
from django.urls import path
from . import views
urlpatterns = [
path("", views.index, name="index"),
]
# mysite/urls.py
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path("polls/", include("polls.urls")),
path("admin/", admin.site.urls),
]
$ curl -si http://127.0.0.1:8000/polls/
HTTP/1.1 200 OK
...
Hello, world. You're at the polls index.
How it works: path(route, view, kwargs=None, name=None) registers one pattern; include() hands the rest of the URL
to another URLconf after chopping off the part that already matched (polls/). So the app can be mounted under
/polls/, /fun_polls/ or /content/polls/ without changing its own file. Always use include() for other URL
patterns; the only exception is admin.site.urls, which is a ready-made URLconf. Patterns are tried in order and the first
match wins. A test for this stage is three lines:
# polls/tests.py
from django.test import SimpleTestCase
class IndexTests(SimpleTestCase):
def test_index(self):
response = self.client.get("/polls/")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content, b"Hello, world. You're at the polls index.")
Source: Writing and running tests
(manage.py test ran this in a throw-away project: Ran 1 test … OK.)
Step 2: the database and the first models (tutorial part 2)
INSTALLED_APPS lists the default apps (admin, auth, contenttypes, sessions, messages, staticfiles), several of
which need tables, so apply their migrations first:
python manage.py migrate
Then define the data. A model is a class that subclasses django.db.models.Model; every class attribute that is a
Field becomes a column:
# polls/models.py
import datetime
from django.contrib import admin
from django.db import models
from django.utils import timezone
class Question(models.Model):
question_text = models.CharField(max_length=200)
pub_date = models.DateTimeField("date published")
def __str__(self):
return self.question_text
@admin.display(
boolean=True,
ordering="pub_date",
description="Published recently?",
)
def was_published_recently(self):
now = timezone.now()
return now - datetime.timedelta(days=1) <= self.pub_date <= now
class Choice(models.Model):
question = models.ForeignKey(Question, on_delete=models.CASCADE)
choice_text = models.CharField(max_length=200)
votes = models.IntegerField(default=0)
def __str__(self):
return self.choice_text
$ python manage.py makemigrations polls
Migrations for 'polls':
polls/migrations/0001_initial.py
+ Create model Question
+ Create model Choice
$ python manage.py migrate
Operations to perform:
Apply all migrations: admin, auth, contenttypes, polls, sessions
Running migrations:
Applying polls.0001_initial... OK
The rule to remember is the three-step loop for every model change: edit models.py, run makemigrations to write a
migration file, run migrate to apply it. The two commands are separate because migration files are committed and shipped with the
code, so production applies the same history as development. manage.py check validates the project without touching the database or
creating migrations, and manage.py sqlmigrate polls 0001 prints the SQL a migration will run.
max_length on CharField is required and is used both for the database column and for validation. ForeignKey needs
on_delete; CASCADE deletes the choices with their question. Django 6.1 adds database-level alternatives (DB_CASCADE,
DB_SET_NULL, DB_SET_DEFAULT) that let the database delete rows without loading them into Python; they do not send
the pre_delete and post_delete signals. No primary key is declared: Django adds an automatic id
(BigAutoField by default since 6.0; since 3.2 it was controlled by DEFAULT_AUTO_FIELD). The @admin.display
decorator on the method (since Django 3.2; it replaces the older short_description and boolean function attributes)
configures how the admin shows it and is used in step 6.
Step 3: the shell and the ORM (tutorial part 2)
python manage.py shell
shell runs a Python prompt with DJANGO_SETTINGS_MODULE set and Django initialised, which plain python does not. Since 5.2 the
shell imports the models of all installed apps automatically, and since 6.0 it also imports common utilities; run it with -v 2
to see the list (on this project it imported 14 objects, including settings, connection, models, timezone,
User, Group, Choice and Question):
>>> Question.objects.all()
<QuerySet []>
>>> q = Question(question_text="What's new?", pub_date=timezone.now())
>>> q.save()
>>> q.id
1
>>> q.question_text = "What's up?"
>>> q.save()
>>> Question.objects.filter(question_text__startswith="What")
<QuerySet [<Question: What's up?>]>
>>> q.choice_set.create(choice_text="Not much", votes=0)
<Choice: Not much>
>>> q.choice_set.create(choice_text="The sky", votes=0)
<Choice: The sky>
>>> q.choice_set.count()
2
>>> Choice.objects.filter(question__pub_date__year=timezone.now().year)
<QuerySet [<Choice: Not much>, <Choice: The sky>]>
Older tutorials start this session with from polls.models import Choice, Question; with Django 5.2 or newer that
line is no longer needed. Use timezone.now() rather than datetime.datetime.now() for stored
datetimes: time zone support is on by default (USE_TZ = True), so Django expects aware datetimes. choice_set is the
reverse side of the ForeignKey, named after the model by default. save() must be called explicitly.
Step 4: views that use templates (tutorial part 3)
Part 3 replaces the stub views by ones that read the database and render HTML. The pieces are the <int:question_id> path
converter (which captures a segment of the URL as an int and passes it to the view as a keyword argument), the render()
shortcut, get_object_or_404(), a template, and namespaced URL names:
# polls/urls.py (function-view version, before generic views)
from django.urls import path
from . import views
app_name = "polls"
urlpatterns = [
path("", views.index, name="index"),
path("<int:question_id>/", views.detail, name="detail"),
path("<int:question_id>/results/", views.results, name="results"),
path("<int:question_id>/vote/", views.vote, name="vote"),
]
# polls/views.py (function-view version)
from django.shortcuts import get_object_or_404, render
from .models import Question
def index(request):
latest_question_list = Question.objects.order_by("-pub_date")[:5]
context = {"latest_question_list": latest_question_list}
return render(request, "polls/index.html", context)
def detail(request, question_id):
question = get_object_or_404(Question, pk=question_id)
return render(request, "polls/detail.html", {"question": question})
{# polls/templates/polls/index.html #}
{% if latest_question_list %}
<ul>
{% for question in latest_question_list %}
<li><a href="{% url 'polls:detail' question.id %}">{{ question.question_text }}</a></li>
{% endfor %}
</ul>
{% else %}
<p>No polls are available.</p>
{% endif %}
Notes that matter:
-
Template location and namespacing. The default
TEMPLATESsetting uses theDjangoTemplatesbackend withAPP_DIRS: True, so Django looks in atemplates/directory inside every installed app. The file ispolls/templates/polls/index.htmland is referred to aspolls/index.html. The doubledpolls/is deliberate: Django returns the first template whose name matches, so unnamespaced templates from two apps would shadow each other. -
render(request, template_name, context)loads the template, renders it with the context (a dictionary) and returns anHttpResponse. Passingrequestis what makes context processors (such asrequest,authandmessages) available. It replaces the older two-steploader.get_template(…)plusHttpResponse(…), and therender_to_response()that Django 3.0 removed. -
get_object_or_404()raisesHttp404when no row matches;get_list_or_404()does the same for an empty list. The helper lives indjango.shortcutson purpose: the model layer must not know about HTTP. -
{% url 'polls:detail' question.id %}reverses a URL from its name, so the link survives a change to the pattern. Theapp_name = "polls"line creates thepolls:namespace so two apps can both have adetailview. Hard-coded paths such as/polls/{{ question.id }}/(the tutorial’s first draft) break as soon as the mount point moves. -
Template variables use dot lookup: dictionary key, then attribute, then list index;
question.choice_set.allcalls the method. -
The tutorial’s templates are intentionally fragments. Real projects should use complete HTML documents (see the pitfall about the debug toolbar below).
Step 5: a form that changes data (tutorial part 4)
{# polls/templates/polls/detail.html #}
<form action="{% url 'polls:vote' question.id %}" method="post">
{% csrf_token %}
<fieldset>
<legend><h1>{{ question.question_text }}</h1></legend>
{% if error_message %}<p><strong>{{ error_message }}</strong></p>{% endif %}
{% for choice in question.choice_set.all %}
<input type="radio" name="choice" id="choice{{ forloop.counter }}" value="{{ choice.id }}">
<label for="choice{{ forloop.counter }}">{{ choice.choice_text }}</label><br>
{% endfor %}
</fieldset>
<input type="submit" value="Vote">
</form>
# polls/views.py (the vote view)
from django.db.models import F
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, render
from django.urls import reverse
from .models import Choice, Question
def vote(request, question_id):
question = get_object_or_404(Question, pk=question_id)
try:
selected_choice = question.choice_set.get(pk=request.POST["choice"])
except (KeyError, Choice.DoesNotExist):
# Redisplay the question voting form.
return render(
request,
"polls/detail.html",
{
"question": question,
"error_message": "You didn't select a choice.",
},
)
else:
selected_choice.votes = F("votes") + 1
selected_choice.save()
# Always redirect after a successful POST so a refresh does not vote twice.
return HttpResponseRedirect(reverse("polls:results", args=(question.id,)))
Three habits are taught by this one view and are worth memorising. State-changing forms use method="post" and carry
{% csrf_token %}; a POST without the token is rejected. This was checked with curl:
$ curl -s -o /dev/null -w '%{http_code}\n' -X POST -d choice=1 http://127.0.0.1:8000/polls/1/vote/
403 # "Forbidden (CSRF cookie not set.)" in the server log
$ curl -s -c jar http://127.0.0.1:8000/polls/1/ | grep csrfmiddlewaretoken # take the token from the page
$ curl -si -b jar -X POST -d choice=1 -d csrfmiddlewaretoken=TOKEN \
-e http://127.0.0.1:8000/ http://127.0.0.1:8000/polls/1/vote/
HTTP/1.1 302 Found
Location: /polls/1/results/
The vote is counted with F("votes") + 1, which makes the database perform the increment, so two simultaneous voters
cannot overwrite each other’s update (a read-modify-write in Python would lose one). One caveat: after save() the Python object
holds the F expression, not a number, so call refresh_from_db() before reading votes again in the same request. The
view always answers a successful POST with a redirect (the Post/Redirect/Get pattern), so the Back button or a refresh
does not resubmit the form. request.POST values are always strings, and request.POST["choice"] raises KeyError when
the field is missing, which the view handles.
Step 6: generic views (tutorial part 4)
The detail, results and index views are the most common pattern of web development (fetch by key, render a template), so Django provides class-based generic views for them. The tutorial refactors to:
# polls/urls.py (final)
from django.urls import path
from . import views
app_name = "polls"
urlpatterns = [
path("", views.IndexView.as_view(), name="index"),
path("<int:pk>/", views.DetailView.as_view(), name="detail"),
path("<int:pk>/results/", views.ResultsView.as_view(), name="results"),
path("<int:question_id>/vote/", views.vote, name="vote"),
]
# polls/views.py (generic-view version; vote() is unchanged)
from django.utils import timezone
from django.views import generic
from .models import Question
class IndexView(generic.ListView):
template_name = "polls/index.html"
context_object_name = "latest_question_list"
def get_queryset(self):
"""Return the last five published questions (not including future ones)."""
return Question.objects.filter(pub_date__lte=timezone.now()).order_by(
"-pub_date"
)[:5]
class DetailView(generic.DetailView):
model = Question
template_name = "polls/detail.html"
def get_queryset(self):
"""Excludes any questions that aren't published yet."""
return Question.objects.filter(pub_date__lte=timezone.now())
class ResultsView(generic.DetailView):
model = Question
template_name = "polls/results.html"
DetailView expects the URL keyword to be named pk (hence <int:pk>), derives the context variable name question from the
model, and would default to the template polls/question_detail.html; template_name overrides that. ListView
names its context variable question_list and defaults to polls/question_list.html, so the tutorial sets
context_object_name and template_name. The timezone.now() filter is added in tutorial part 5 so that future-dated
questions stay hidden. Class-based views are covered on their own page of this section.
Step 7: automated tests (tutorial part 5)
# polls/tests.py (excerpt)
import datetime
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from .models import Question
def create_question(question_text, days):
time = timezone.now() + datetime.timedelta(days=days)
return Question.objects.create(question_text=question_text, pub_date=time)
class QuestionModelTests(TestCase):
def test_was_published_recently_with_future_question(self):
time = timezone.now() + datetime.timedelta(days=30)
self.assertIs(Question(pub_date=time).was_published_recently(), False)
class QuestionIndexViewTests(TestCase):
def test_no_questions(self):
response = self.client.get(reverse("polls:index"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "No polls are available.")
self.assertQuerySetEqual(response.context["latest_question_list"], [])
def test_past_question(self):
question = create_question(question_text="Past question.", days=-30)
response = self.client.get(reverse("polls:index"))
self.assertQuerySetEqual(response.context["latest_question_list"], [question])
$ python manage.py test polls
Found 10 test(s).
System check identified no issues (0 silenced).
Creating test database for alias 'default'...
..........
----------------------------------------------------------------------
Ran 10 tests in 0.016s
OK
Destroying test database for alias 'default'...
Tests run against a separate, temporary database (an in-memory SQLite database here), so your real data is never touched, and
TestCase wraps every test in a transaction that is rolled back. The built-in test client (self.client) makes requests
without a network and exposes response.context and assertContains. The tutorial’s most important lesson is to write the failing test first for a
bug (was_published_recently() returned True for questions dated in the future) and then fix the code. Note the method name
assertQuerySetEqual: the old spelling assertQuerysetEqual was deprecated in Django 4.2 and removed in 5.1, so older
tutorials fail with AttributeError on current versions. Testing is the subject of its own pages in this section.
Step 8: static files (tutorial part 6)
Static files (CSS, JavaScript, images) live in static/ directories next to templates and are namespaced the same way:
polls/static/polls/style.css. The {% load static %} tag and {% static %} generate the URL from STATIC_URL:
{# top of polls/templates/polls/index.html #}
{% load static %}
<link rel="stylesheet" href="{% static 'polls/style.css' %}">
Under runserver with DEBUG = True, django.contrib.staticfiles serves them from the app directories (the
curl check returned HTTP 200, Content-Type: text/css). In production there is no such magic: collectstatic gathers files into
STATIC_ROOT and a web server or WhiteNoise serves them, which the Static & Media Files page covers. Old books write
{% load staticfiles %} or {% load admin_static %}; those tag libraries were removed in Django 3.0, use {% load static %}.
Step 9: the admin (tutorial parts 2 and 7)
Create an administrator and register the models:
python manage.py createsuperuser
createsuperuser prompts for a username, an optional email address and a password (twice); never script a real password into a command line or
a repository. Then customise the admin as in part 7:
# polls/admin.py
from django.contrib import admin
from .models import Choice, Question
class ChoiceInline(admin.TabularInline):
model = Choice
extra = 3
class QuestionAdmin(admin.ModelAdmin):
fieldsets = [
(None, {"fields": ["question_text"]}),
("Date information", {"fields": ["pub_date"], "classes": ["collapse"]}),
]
inlines = [ChoiceInline]
list_display = ["question_text", "pub_date", "was_published_recently"]
list_filter = ["pub_date"]
search_fields = ["question_text"]
admin.site.register(Question, QuestionAdmin)
Visit /admin/, log in and you get create, change, delete and history screens for questions, with Choice rows edited inline on the question
page (TabularInline is the compact layout, StackedInline the roomy one), a change list with the columns of list_display, a date
filter and a search box. Two ModelAdmin facts help to read the code: list_display entries may be methods of the model (here with the
@admin.display decorator from step 2), and fieldsets controls grouping, the collapse class hides a group behind a toggle. The admin is a
page of its own in this section.
A Tour of Tutorial Parts 1 to 8
The official tutorial is eight parts long and is the best single introduction; this table maps each part to what it teaches, where it appears above and the modern detail to keep in mind.
| Part | What it builds | Read it with this in mind |
|---|---|---|
|
The development server is not for production. |
|
Database setup, |
Three-step loop for model changes; |
|
More views with URL arguments, templates, |
Namespace templates and static files with the app name. Use |
|
A POST form, CSRF, |
Never change data on GET. Redirect after POST. |
|
|
|
|
|
|
|
|
Prefer small |
|
Installing and configuring |
Check that a package supports your Django version; follow its own install guide. |
Source: Writing your first Django app, part 1 through part 8
Part 8 in practice: django-debug-toolbar
Part 8 installs django-debug-toolbar (version 8.0.0 on 2026-10-03, which declares support for Django 5.2, 6.0 and 6.1)
as the model for every other ecosystem package: install with pip or uv, add the app, add the middleware, add the URL,
set INTERNAL_IPS:
# mysite/settings.py
INSTALLED_APPS = [
# ...
"django.contrib.staticfiles",
"debug_toolbar",
]
MIDDLEWARE = [
"debug_toolbar.middleware.DebugToolbarMiddleware",
# ... the rest, unchanged
]
INTERNAL_IPS = ["127.0.0.1"]
# mysite/urls.py
urlpatterns = [
path("__debug__/", include("debug_toolbar.urls")),
# ... the rest, unchanged
]
Each toolbar setup step above is described in the package’s own installation guide (linked from the tutorial); this was applied to
a copy of the project and check stayed clean. A pitfall tied to the tutorial’s note that its templates are incomplete HTML:
the toolbar injects its panel just before </body>, so on the tutorial’s fragment pages (/polls/1/) the toolbar was
absent (0 matches for its markup), while on a full HTML page such as /admin/login/ it appeared. If the toolbar "does
not show", check that the response is a complete HTML document, that DEBUG is on and that the request IP is in INTERNAL_IPS.
Sync and Async Notes
Everything above is synchronous: a WSGI server calls your view in a worker thread, the ORM blocks that thread while it waits for the database, and
that is a perfectly good default. Since Django 3.1 views and middleware may be async def, and the ORM has
asynchronous methods (aget, acount, async for over a queryset) since 4.1. An async view runs natively under ASGI
(asgi.py, served by Uvicorn, Daphne or Granian); under WSGI, including runserver, Django adapts it to the synchronous path
so it works but gains no concurrency. This view was added to the polls app and answered correctly under both servers:
# polls/views.py
from django.http import JsonResponse
from .models import Question
async def ping(request):
count = await Question.objects.acount()
return JsonResponse({"questions": count})
# polls/urls.py (add to urlpatterns)
path("ping/", views.ping, name="ping"),
Source: Asynchronous support
$ python -m uvicorn mysite.asgi:application --port 8000
$ curl -si http://127.0.0.1:8000/polls/ping/
HTTP/1.1 200 OK
...
{"questions": 1}
Source: How to deploy with ASGI
Do not mix blocking calls (a plain ORM query, requests, time.sleep) into an async def view: Django raises
SynchronousOnlyOperation for ORM access from an async context, and other blocking calls stall the whole event loop.
Prefer plain def views unless you need concurrency, WebSockets or server-sent events. These topics have dedicated pages.
The System Check Framework and Deployment Checks
runserver and manage.py check run Django’s system checks, which catch misconfigurations before a request arrives. The
development-friendly generated settings fail the stricter deployment profile on purpose, which is a useful reading list of
what must change before production. Output from the generated project on Django 6.1.1:
$ python manage.py check
System check identified no issues (0 silenced).
$ python manage.py check --deploy
?: (mail.E001) Your MAILERS setting uses a development-only email backend in the 'default' entry (django.core.mail.backends.console.EmailBackend).
HINT: Use a production-ready email backend, such as the SMTP backend, otherwise email will not be sent.
?: (security.W004) You have not set a value for the SECURE_HSTS_SECONDS setting. ...
?: (security.W008) Your SECURE_SSL_REDIRECT setting is not set to True. ...
?: (security.W009) Your SECRET_KEY has less than 50 characters, less than 5 unique characters, or it's prefixed with 'django-insecure-' ...
?: (security.W012) SESSION_COOKIE_SECURE is not set to True. ...
?: (security.W016) You have 'django.middleware.csrf.CsrfViewMiddleware' in your MIDDLEWARE, but you have not set CSRF_COOKIE_SECURE to True. ...
?: (security.W018) You should not have DEBUG set to True in deployment.
?: (security.W020) ALLOWED_HOSTS must not be empty in deployment.
System check identified 8 issues (0 silenced).
Source: django-admin check
check also accepts app labels, --tag to restrict the categories, --database for checks that need a connection, --list-tags
and --fail-level. mail.E001 is new in 6.1: it is a deployment-only check that refuses a development email backend in the
default mailer (together with mail.W001, which warns when MAILERS is defined without a default entry). Running
check --deploy in CI is a cheap guard; the Deployment and Security pages of this section treat each warning.
Pitfalls Checklist
-
Wrong interpreter. Django 6.0 and later need Python 3.12 or newer. An older Python makes
pipreport "No matching distribution". -
Installing into the system Python. Use a virtual environment per project and run
python -m django --versionto see which Django you actually get. -
Forgetting to register the app. Models in an app that is not in
INSTALLED_APPSare not migrated;makemigrations pollsthen answers "No installed app with label 'polls'" (seen while preparing this page). -
Skipping
include()or putting/polls/twice in the browser URL: the root URLconf strips the matched prefix. -
Editing models without migrating. Run
makemigrationsandmigrate; commit the migration files. -
Un-namespaced templates and static files shadowing each other across apps.
-
Changing data over GET or omitting
{% csrf_token %}in a POST form (the request fails with 403). -
Treating
runserveras production. It is neither hardened nor tuned; deploy behind Gunicorn or an ASGI server. -
Leaving the generated
SECRET_KEY,DEBUG = Trueand an emptyALLOWED_HOSTSin deployed settings. Runcheck --deploy. -
Reading a stale tutorial. See the next section.
Where the Books Are Outdated
Introductory books and blog posts written between Django 1.11 and Django 3 remain useful for the ideas (the project/app split, the model-view-template flow, forms, the admin) but several commands and imports no longer work on Django 6.1. Check these first when a sample fails:
-
Python and installation. Books that install with
virtualenv,pipenvoreasy_installand a Python 2 or early Python 3 interpreter do not match the 3.12 to 3.14 window. The installation steps in this page (venv or uv, a pinned series) are the current ones. -
URL routing. The regular-expression
url()function fromdjango.conf.urlswas removed in Django 4.0. Usepath()(added in 2.0) orre_path(). Likewisedjango.core.urlresolverswas removed in 2.0 in favour ofdjango.urls. -
Registering apps.
default_app_configininit.pyis obsolete (automatic discovery since 3.2), and theAppConfigis referred to by"polls"or"polls.apps.PollsConfig". -
Settings.
MIDDLEWARE_CLASSESwas replaced byMIDDLEWAREin 2.0; the template tag librariesstaticfilesandadmin_staticwere removed in 3.0;USE_L10Nwas removed in 5.0;DEFAULT_AUTO_FIELDis no longer in generated projects since 6.0; andDEFAULT_FILE_STORAGE/STATICFILES_STORAGEwere replaced bySTORAGES(4.2) and removed in 5.1. -
Tests.
assertQuerysetEqualisassertQuerySetEqualsince 4.2 (old name removed in 5.1). -
Admin. Method attributes such as
short_descriptionandbooleanare replaced by the@admin.displaydecorator (3.2). -
Layout. Older books place the project in a directory named after itself (
mysite/mysite) and showmanage.pycommands without the shell auto-imports; both remain valid but the destination argument and the shell imports make the modern flow shorter. -
Async. ASGI (3.0), async views (3.1), the async ORM (4.1) and async authentication (5.0) postdate nearly every book; none of them is required to follow this page.
Source: Django Deprecation Timeline
What Changed Recently
-
Django 6.1 (2026-08-05). Python 3.12 to 3.14; PostgreSQL 15 or newer. New: model field fetch modes, database-level delete options for
ForeignKey.on_delete(DB_CASCADE,DB_SET_NULL,DB_SET_DEFAULT), and theMAILERSsetting, which is generated in new projects (it will replaceEMAIL_BACKENDand the otherEMAIL_*settings in Django 2028, and the old settings andget_connection()already emit deprecation warnings). Calendar versioning (DEP 20): the releases planned as 7.0 and 7.1 are now Django 2028 and 2029; the matching rename of the deprecation warning classes (RemovedInDjango70WarningtoRemovedInDjango2028Warning, old name kept as an alias) was announced in 6.1 and ships in a 6.1 patch after 6.1.1. Management commands enablesuggest_on_erroron Python 3.14, so typos in subcommand names get a suggestion. -
Django 6.0.
DEFAULT_AUTO_FIELDdefaults toBigAutoFieldand is no longer generated;startprojectandstartappcreate a missing destination directory; the shell auto-importssettingsand other common utilities; template partials, thedjango.tasksframework and built-in Content Security Policy support arrive (each has its own page). -
Django 5.2 LTS. The shell automatically imports models from installed apps; Python 3.14 added in 5.2.8.
-
Release rhythm. 5.2 is the long-term-support release until April 2028; 6.2 LTS is planned for April 2027. For learning, use the newest release; for a long-lived project, plan upgrades through the LTS releases and run your tests with
python -W error::DeprecationWarning(or-Wall) so deprecations surface early.
Where to Go Next
The official "What to read next" page points to the topic guides (models, views, templates, forms), the reference and the how-to guides. In this section the next steps are the Introduction & Architecture, Project Structure & Apps and Settings & Configuration pages, followed by the models, URLs and views, and templates pages, which re-visit each tutorial part in depth with the Bookshelf scenario.