Type something to search...
Getting Started with Django: Building Your First Web App

Getting Started with Django: Building Your First Web App

Django has been around for twenty years, and it's still one of the fastest ways to go from an empty folder to a working, database-backed web app. It ships with an ORM, a migration system, an admin interface, a template engine, form handling, authentication, and security defaults, all designed to work together. The trade-off is that there's a lot of it, and the first hour can feel like you're memorizing where files go rather than building anything.

This guide skips the tour and builds something real: a small task tracker where you can list tasks, view one, create new ones through a validated form, mark them done, and manage everything through the built-in admin. Along the way you'll see how a request actually flows through a Django project, which makes every other Django feature easier to place.

The examples were tested with Django 6.1 on Python 3.13. Django 6.x requires Python 3.12 or newer.

Setting Up the Project

Start with a virtual environment so Django is installed for this project only, not globally:

mkdir tasktracker && cd tasktracker
python3 -m venv .venv
source .venv/bin/activate        # Windows: .venv\Scripts\activate
python -m pip install django
python -m django --version

If you use uv, uv init followed by uv add django does the same job.

Now create the project. The trailing . tells Django to put manage.py in the current folder instead of nesting another directory:

django-admin startproject config .

Naming the project package config is a common convention, because that's all it really holds: settings and the top-level URL configuration. You get this layout:

tasktracker/
├── manage.py
└── config/
    ├── __init__.py
    ├── asgi.py
    ├── settings.py
    ├── urls.py
    └── wsgi.py
  • manage.py is the command-line entry point for everything: running the dev server, creating migrations, opening a shell, running tests.
  • config/settings.py holds configuration: installed apps, middleware, database, templates, time zone.
  • config/urls.py is the root URL map. Every request starts here.
  • asgi.py and wsgi.py are entry points for production servers. You won't touch them yet.

Run the development server to check everything works:

python manage.py runserver

Visit http://127.0.0.1:8000/ and you'll see Django's welcome page. The terminal also warns about unapplied migrations; that's next.

Projects vs Apps

Django splits code into a project (the whole site, its settings and root URLs) and apps (self-contained features, each with its own models, views, templates and URLs). A blog site might have a posts app, an accounts app, and a newsletter app. Apps are just Python packages, so the idea maps onto modules and packages you already know.

Create a tasks app:

python manage.py startapp tasks

Then register it in INSTALLED_APPS so Django knows to look for its models, templates, and migrations:

# config/settings.py
INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "tasks",
]

Forgetting this step is the most common reason a new app's models don't show up in migrations or its templates aren't found.

Defining a Model

A model is a Python class that maps to a database table. Each attribute is a column. Here's the task model:

# tasks/models.py
from django.db import models
from django.urls import reverse


class Task(models.Model):
    class Priority(models.IntegerChoices):
        LOW = 1, "Low"
        NORMAL = 2, "Normal"
        HIGH = 3, "High"

    title = models.CharField(max_length=200)
    notes = models.TextField(blank=True)
    priority = models.IntegerField(choices=Priority, default=Priority.NORMAL)
    done = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["done", "-priority", "-created_at"]

    def __str__(self) -> str:
        return self.title

    def get_absolute_url(self) -> str:
        return reverse("tasks:detail", args=[self.pk])

A few things worth noticing:

  • You don't declare an id column. Django adds an auto-incrementing primary key, available as both task.id and task.pk.
  • Priority is an IntegerChoices enum. The database stores 1, 2, or 3, while forms and the admin show "Low", "Normal", "High". If you've used Python's enums, this is the same idea with labels attached.
  • blank=True on notes means forms accept an empty value. It's a validation rule, not a database rule (that's null=True, which you usually avoid on text fields).
  • auto_now_add=True stamps the creation time automatically.
  • Meta.ordering sets the default sort: open tasks first, then highest priority, then newest.
  • __str__ controls how a task appears in the admin and the shell. get_absolute_url gives each object a canonical URL, which redirect() and the admin's "View on site" button both use.

Migrations: Turning Models into Tables

Django doesn't create tables directly from your classes. It generates migration files that describe the change, and then applies them. That two-step process is what lets you evolve a schema safely over time.

python manage.py makemigrations tasks
python manage.py migrate

The first command writes tasks/migrations/0001_initial.py:

Migrations for 'tasks':
  tasks/migrations/0001_initial.py
    + Create model Task

The second applies it, along with the migrations for the built-in apps (users, sessions, admin log, and so on). By default everything goes into a SQLite file called db.sqlite3 in the project root, which is perfect for development.

Commit migration files to version control. They're part of your code, and every environment (your laptop, CI, production) replays the same files to reach the same schema. Whenever you change a model, run makemigrations again and Django will write only the difference.

Exploring the ORM in the Shell

Before building pages, try the model interactively:

python manage.py shell

Recent Django versions auto-import your models in the shell, so Task is available immediately:

>>> t = Task.objects.create(title="Write the quarterly report", priority=Task.Priority.HIGH)
>>> Task.objects.create(title="Water the plants")
>>> Task.objects.count()
2
>>> Task.objects.filter(done=False).first()
<Task: Write the quarterly report>
>>> t.get_priority_display()
'High'
>>> Task.objects.filter(title__icontains="report").exists()
True
>>> t.get_absolute_url()
'/1/'

Task.objects is the model's manager, and calls like filter() return a QuerySet. QuerySets are lazy: no SQL runs until you iterate, slice, count, or otherwise need the results. The double-underscore syntax (title__icontains) is a lookup; there are lookups for comparisons (priority__gte=2), date parts (created_at__year=2026), related fields, and more. get_priority_display() is generated automatically for any field with choices.

(The get_absolute_url() call works here because the URLs are wired up in the next sections. If you try it before then, you'll get a NoReverseMatch error.)

The Admin: A Free Back Office

The admin is one of Django's standout features. Register the model:

# tasks/admin.py
from django.contrib import admin

from .models import Task


@admin.register(Task)
class TaskAdmin(admin.ModelAdmin):
    list_display = ["title", "priority", "done", "created_at"]
    list_filter = ["done", "priority"]
    search_fields = ["title", "notes"]

Create a user who can log in to it:

python manage.py createsuperuser

Run the server and open http://127.0.0.1:8000/admin/. You get a full create/read/update/delete interface for tasks, with columns, sidebar filters, and search, from those few lines. For internal tools, it's often all the UI you need. For a public site, treat it as a staff back office rather than something end users see.

Views, URLs, and Templates

Here's how a request flows through Django:

  1. The browser requests /3/.
  2. Django checks config/urls.py, which delegates to tasks/urls.py.
  3. A URL pattern matches and calls a view function with the request and any captured values (pk=3).
  4. The view fetches data and renders a template into an HTTP response.

Views

Views are plain functions that take a request and return a response:

# tasks/views.py
from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.http import require_POST

from .forms import TaskForm
from .models import Task


def task_list(request):
    tasks = Task.objects.all()
    open_count = tasks.filter(done=False).count()
    return render(
        request,
        "tasks/task_list.html",
        {"tasks": tasks, "open_count": open_count},
    )


def task_detail(request, pk: int):
    task = get_object_or_404(Task, pk=pk)
    return render(request, "tasks/task_detail.html", {"task": task})


def task_create(request):
    if request.method == "POST":
        form = TaskForm(request.POST)
        if form.is_valid():
            task = form.save()
            return redirect(task)
    else:
        form = TaskForm()
    return render(request, "tasks/task_form.html", {"form": form})


@require_POST
def task_toggle(request, pk: int):
    task = get_object_or_404(Task, pk=pk)
    task.done = not task.done
    task.save(update_fields=["done"])
    return redirect("tasks:list")
  • render() loads a template, fills it with a context dictionary, and returns an HttpResponse.
  • get_object_or_404() turns a missing row into a proper 404 instead of a crash.
  • redirect() accepts a model instance (it calls get_absolute_url()), a URL name, or a literal path.
  • task_toggle changes data, so it only accepts POST. @require_POST returns a 405 for anything else. Never change data on a GET request; crawlers and link prefetchers follow GET links.

task_create uses the standard pattern for forms: show an empty form on GET; on POST, bind the submitted data, validate it, and either save and redirect, or re-render the form with errors. Redirecting after a successful POST (the Post/Redirect/Get pattern) stops a browser refresh from submitting the form twice.

Django also has class-based generic views (ListView, DetailView, CreateView) that compress these patterns into a few lines. They're worth learning, but function views make the mechanics visible, which is what you want first.

URLs

Give the app its own URL file:

# tasks/urls.py
from django.urls import path

from . import views

app_name = "tasks"

urlpatterns = [
    path("", views.task_list, name="list"),
    path("new/", views.task_create, name="create"),
    path("<int:pk>/", views.task_detail, name="detail"),
    path("<int:pk>/toggle/", views.task_toggle, name="toggle"),
]

Then include it from the root:

# config/urls.py
from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path("admin/", admin.site.urls),
    path("", include("tasks.urls")),
]

<int:pk> is a path converter. It matches digits and passes them to the view as an integer keyword argument called pk. Other converters include str, slug, and uuid.

app_name creates a namespace, so routes are referenced as "tasks:list" or "tasks:detail". Always refer to URLs by name, in Python with reverse() and in templates with {% url %}, rather than hardcoding paths. Then you can change "new/" to "add/" in one place without breaking every link.

Templates

With APP_DIRS enabled (the default), Django looks for templates in each app's templates/ folder. The extra tasks/ subfolder namespaces them so two apps can both have a list.html without colliding:

tasks/templates/tasks/
├── base.html
├── task_list.html
├── task_detail.html
└── task_form.html

Start with a base layout that other templates extend:

<!-- tasks/templates/tasks/base.html -->
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>{% block title %}Tasks{% endblock %}</title>
  </head>
  <body>
    <header>
      <a href="{% url 'tasks:list' %}">All tasks</a> |
      <a href="{% url 'tasks:create' %}">New task</a>
    </header>
    <main>{% block content %}{% endblock %}</main>
  </body>
</html>

The list page fills in the content block:

<!-- tasks/templates/tasks/task_list.html -->
{% extends "tasks/base.html" %} {% block content %}
<h1>Tasks ({{ open_count }} open)</h1>
<ul>
  {% for task in tasks %}
  <li>
    <a href="{{ task.get_absolute_url }}">{{ task.title }}</a>
    [{{ task.get_priority_display }}] {% if task.done %}(done){% endif %}
    <form
      method="post"
      action="{% url 'tasks:toggle' task.pk %}"
      style="display:inline"
    >
      {% csrf_token %}
      <button type="submit">{{ task.done|yesno:"Reopen,Mark done" }}</button>
    </form>
  </li>
  {% empty %}
  <li>No tasks yet.</li>
  {% endfor %}
</ul>
{% endblock %}

The template language is deliberately limited. {{ ... }} outputs a value, {% ... %} runs a tag, and | applies a filter. You can call methods that take no arguments (task.get_absolute_url, without parentheses), but you can't write arbitrary Python. That pushes logic into views and models, where it belongs and where you can test it.

Two safety features are on by default here:

  • Auto-escaping. Every {{ }} value is HTML-escaped, so a task titled <script>alert(1)</script> displays as text instead of running.
  • CSRF protection. Every POST form needs {% csrf_token %}. Without it, Django rejects the submission with a 403. This blocks other sites from submitting forms on your users' behalf.

The detail page:

<!-- tasks/templates/tasks/task_detail.html -->
{% extends "tasks/base.html" %} {% block title %}{{ task.title }}{% endblock %}
{% block content %}
<h1>{{ task.title }}</h1>
<p>Priority: {{ task.get_priority_display }}</p>
<p>Created {{ task.created_at|date:"M j, Y" }}</p>
{{ task.notes|linebreaks }} {% endblock %}

Forms with Validation

A ModelForm builds form fields straight from the model, so you don't define title twice:

# tasks/forms.py
from django import forms

from .models import Task


class TaskForm(forms.ModelForm):
    class Meta:
        model = Task
        fields = ["title", "notes", "priority"]

    def clean_title(self) -> str:
        title = self.cleaned_data["title"].strip()
        if len(title) < 3:
            raise forms.ValidationError("Give the task a slightly longer title.")
        return title

The form inherits the model's rules automatically: title is required with a 200-character limit, notes is optional, and priority renders as a select box restricted to the three choices. A clean_<fieldname>() method adds custom validation for one field; whatever it returns becomes the cleaned value. Always list fields explicitly so a new model field (say, an owner) never becomes user-editable by accident.

The template only needs to render it:

<!-- tasks/templates/tasks/task_form.html -->
{% extends "tasks/base.html" %} {% block title %}New task{% endblock %} {% block
content %}
<h1>New task</h1>
<form method="post">
  {% csrf_token %} {{ form.as_p }}
  <button type="submit">Save</button>
</form>
{% endblock %}

{{ form.as_p }} outputs every field with its label, current value, and any error messages. Once you need precise markup, loop over form and render each field's label_tag, the field itself, and errors yourself.

Run the server and try it: create a task, submit a two-letter title to see the error, toggle tasks done and back.

Testing the App

Django's test runner creates a throwaway test database, runs your tests, and destroys it. The test client lets you make requests without a running server:

# tasks/tests.py
from django.test import TestCase
from django.urls import reverse

from .models import Task


class TaskViewTests(TestCase):
    def test_list_shows_open_count(self):
        Task.objects.create(title="Write report")
        Task.objects.create(title="Ship it", done=True)
        response = self.client.get(reverse("tasks:list"))
        self.assertContains(response, "Tasks (1 open)")

    def test_create_redirects_to_detail(self):
        response = self.client.post(
            reverse("tasks:create"),
            {"title": "Buy milk", "notes": "", "priority": 2},
        )
        task = Task.objects.get()
        self.assertRedirects(response, task.get_absolute_url())

    def test_short_title_is_rejected(self):
        response = self.client.post(
            reverse("tasks:create"), {"title": "ab", "priority": 2}
        )
        self.assertContains(response, "slightly longer title")
        self.assertEqual(Task.objects.count(), 0)
python manage.py test tasks
Found 3 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...
----------------------------------------------------------------------
Ran 3 tests in 0.016s

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

Each test runs inside a transaction that's rolled back afterwards, so tests don't leak data into each other. If you prefer pytest's style, the pytest-django plugin runs the same kind of tests with fixtures; see unit testing with pytest for the basics.

Settings to Know Before You Deploy

The generated settings.py is tuned for development. Before anything goes public:

SettingDevelopmentProduction
DEBUGTrue (detailed error pages)False, always
SECRET_KEYGenerated placeholderLong random value from an environment variable
ALLOWED_HOSTSEmpty (localhost only)Your real domain names
DATABASESSQLite fileOften PostgreSQL, configured from environment variables
Static filesServed by runserverCollected with collectstatic and served by a web server, CDN, or WhiteNoise

Reading secrets from the environment keeps them out of version control:

# config/settings.py
import os

SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
DEBUG = os.environ.get("DJANGO_DEBUG", "") == "1"
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "").split(",")

python manage.py check --deploy audits your settings and flags common security problems. Run it as part of your release process. runserver is for development only; in production, Django runs behind a WSGI or ASGI server such as Gunicorn or Uvicorn. Deploying a Python web app with Docker walks through that setup.

Where to Go Next

With this app working, the natural next steps are:

  • Authentication. django.contrib.auth already provides users, login/logout views, and the @login_required decorator. Add a ForeignKey from Task to the user model and filter by request.user.
  • Class-based views. Rewrite the views with ListView, DetailView, CreateView, and UpdateView and compare.
  • Relationships. Add a Project model and give Task a ForeignKey to it. Learn select_related() and prefetch_related() to avoid N+1 queries.
  • Static files. Add CSS under tasks/static/tasks/ and load it with {% load static %}.
  • An API. Django REST Framework or Django Ninja put a JSON API on top of the same models.

If you're still deciding whether Django is the right fit, Django vs Flask vs FastAPI compares the three main options.

Conclusion

A Django app comes down to a handful of pieces: models describe your data, migrations keep the database in sync with them, URLs map paths to views, views fetch data and render templates, and forms validate input. The admin and the test client come along for free. Once you've built one small app end to end like this, the rest of Django is mostly finding the right built-in feature instead of writing it yourself.

Tags :
Share :

Related Posts

Abstract Base Classes in Python with the abc Module

Abstract Base Classes in Python with the abc Module

Python leans on duck typing: if an object has the method you need, you call it and move on. That works well until you have a family of classes that a

Continue Reading
*args and **kwargs in Python: Flexible Function Signatures

*args and **kwargs in Python: Flexible Function Signatures

You've seen def wrapper(*args, **kwargs): in decorators, and probably super().__init__(**kwargs) in class hierarchies. These two parameters let a

Continue Reading
Asyncio in Python: A Beginner's Guide to Asynchronous Programming

Asyncio in Python: A Beginner's Guide to Asynchronous Programming

A lot of programs spend most of their time waiting. A web scraper waits for pages to download, an API server waits for the database, a chat bot waits

Continue Reading