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

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

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

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)

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.

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 .

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

manage.py

A thin command-line wrapper around django-admin. It sets DJANGO_SETTINGS_MODULE to mysite.settings (with os.environ.setdefault, so a value already in the environment wins) and calls execute_from_command_line. Use it for every command inside the project.

mysite/ (the package)

The Python package of the project; its name is what you import (mysite.urls). Nothing else in it is special.

mysite/init.py

An empty file that marks the directory as a package.

mysite/settings.py

Module-level variables that are the project’s configuration: SECRET_KEY, DEBUG, ALLOWED_HOSTS, INSTALLED_APPS, MIDDLEWARE, ROOT_URLCONF, TEMPLATES, WSGI_APPLICATION, DATABASES, password validators, language and time zone, STATIC_URL and, since 6.1, MAILERS.

mysite/urls.py

The root URLconf, the "table of contents" of the site. ROOT_URLCONF points at it.

mysite/wsgi.py

The entry point (application) for WSGI servers such as Gunicorn.

mysite/asgi.py

The entry point (application) for ASGI servers such as Uvicorn, Daphne or Granian.

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",
    },
}

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
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

[addrport]

A port (8080), an ipaddr:port or a hostname. The default is 127.0.0.1:8000. 0, 0.0.0.0 or :: make the server reachable from other machines on the network, which also requires adding the name to ALLOWED_HOSTS. An IPv6 address goes in brackets, for example [200a::1]:8000, and enables IPv6.

--noreload

Disable the autoreloader; code changes then require a manual restart.

--nothreading

Serve one request at a time. The server is multithreaded by default.

--ipv6, -6

Use IPv6; the default address becomes ::1.

--nostatic

Do not automatically serve static files at STATIC_URL. Only applies when django.contrib.staticfiles is installed, which overrides the command with its own runserver.

--insecure

Serve static files even when DEBUG is False. For testing only.

--skip-checks

Do not run the system checks at startup and on each reload.

--settings, --pythonpath, --no-color, --force-color, --version

Options shared by every management command.

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).

flowchart TD start([Edit code]) --> models["models.py
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

models.py

The app’s data model; every model class becomes a database table.

views.py

The view functions and classes.

admin.py

Registers models with the admin site.

apps.py

The app’s AppConfig subclass (PollsConfig), whose name is the app’s dotted path.

migrations/

Migration files derived from models.py; they are committed to version control and shipped with the app.

tests.py

The place to start writing tests; replace it with a tests/ package when the suite grows.

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.")

(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 TEMPLATES setting uses the DjangoTemplates backend with APP_DIRS: True, so Django looks in a templates/ directory inside every installed app. The file is polls/templates/polls/index.html and is referred to as polls/index.html. The doubled polls/ 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 an HttpResponse. Passing request is what makes context processors (such as request, auth and messages) available. It replaces the older two-step loader.get_template(…​) plus HttpResponse(…​), and the render_to_response() that Django 3.0 removed.

  • get_object_or_404() raises Http404 when no row matches; get_list_or_404() does the same for an empty list. The helper lives in django.shortcuts on 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. The app_name = "polls" line creates the polls: namespace so two apps can both have a detail view. 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.all calls 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

1. Requests and responses

startproject, runserver, startapp, a first view, path() and include().

The development server is not for production. include() always, except for admin.site.urls.

2. Models and the admin

Database setup, migrate, models, makemigrations, the shell and ORM, createsuperuser, registering a model in the admin.

Three-step loop for model changes; TIME_ZONE; the 6.0 BigAutoField default; shell auto-imports (5.2, 6.0).

3. Views and templates

More views with URL arguments, templates, render(), 404 handling, {% url %}, URL namespaces.

Namespace templates and static files with the app name. Use get_object_or_404().

4. Forms and generic views

A POST form, CSRF, F(), redirect-after-POST, ListView and DetailView.

Never change data on GET. Redirect after POST.

5. Testing

TestCase, the test client, test-driven bug fixing, view tests.

assertQuerySetEqual (the old spelling was removed in 5.1). Use timezone.now().

6. Static files

static/ directories, {% static %}, a stylesheet and a background image.

{% load static %}, not staticfiles. Production serving is a separate concern.

7. Customising the admin

fieldsets, inlines, list_display, list_filter, search_fields, overriding admin templates, site_header.

Prefer small ModelAdmin options over template overrides.

8. Third-party packages

Installing and configuring django-debug-toolbar as the example of an ecosystem package.

Check that a package supports your Django version; follow its own install guide.

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"),
$ 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}

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).

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 pip report "No matching distribution".

  • Installing into the system Python. Use a virtual environment per project and run python -m django --version to see which Django you actually get.

  • Forgetting to register the app. Models in an app that is not in INSTALLED_APPS are not migrated; makemigrations polls then 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 makemigrations and migrate; 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 runserver as production. It is neither hardened nor tuned; deploy behind Gunicorn or an ASGI server.

  • Leaving the generated SECRET_KEY, DEBUG = True and an empty ALLOWED_HOSTS in deployed settings. Run check --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, pipenv or easy_install and 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 from django.conf.urls was removed in Django 4.0. Use path() (added in 2.0) or re_path(). Likewise django.core.urlresolvers was removed in 2.0 in favour of django.urls.

  • Registering apps. default_app_config in init.py is obsolete (automatic discovery since 3.2), and the AppConfig is referred to by "polls" or "polls.apps.PollsConfig".

  • Settings. MIDDLEWARE_CLASSES was replaced by MIDDLEWARE in 2.0; the template tag libraries staticfiles and admin_static were removed in 3.0; USE_L10N was removed in 5.0; DEFAULT_AUTO_FIELD is no longer in generated projects since 6.0; and DEFAULT_FILE_STORAGE/STATICFILES_STORAGE were replaced by STORAGES (4.2) and removed in 5.1.

  • Tests. assertQuerysetEqual is assertQuerySetEqual since 4.2 (old name removed in 5.1).

  • Admin. Method attributes such as short_description and boolean are replaced by the @admin.display decorator (3.2).

  • Layout. Older books place the project in a directory named after itself (mysite/mysite) and show manage.py commands 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.

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 the MAILERS setting, which is generated in new projects (it will replace EMAIL_BACKEND and the other EMAIL_* settings in Django 2028, and the old settings and get_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 (RemovedInDjango70Warning to RemovedInDjango2028Warning, old name kept as an alias) was announced in 6.1 and ships in a 6.1 patch after 6.1.1. Management commands enable suggest_on_error on Python 3.14, so typos in subcommand names get a suggestion.

  • Django 6.0. DEFAULT_AUTO_FIELD defaults to BigAutoField and is no longer generated; startproject and startapp create a missing destination directory; the shell auto-imports settings and other common utilities; template partials, the django.tasks framework 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.