PythonMastery
intermediate 24 min read · lesson 9 of 12 in Web Frameworks

Django: Batteries-Included Web

1 · The lesson

read

Where Flask says "bring your own everything" and FastAPI says "bring your own ORM, but here's the API layer", Django says "we've already picked, and we've been picking for twenty years". You get an ORM, a templating engine, an auth system, a forms framework, a migration tool, and — the headline feature — a fully working admin UI, all from one pip install. The cost is convention: there is a Django way to do most things, and fighting it is unproductive.

This lesson walks through the project layout, the URL-to-view-to-template pipeline, the settings split, and the legendary admin site. By the end you'll have a tasks app rendering a real page from a real database.

Run locally with pip install django and python manage.py runserver. Django doesn't run in Pyodide — it needs a real Python interpreter listening on a socket. Expected output is shown in comments.


1. Why Django — When Batteries Beat Choice

Flask gives you HTTP, routing, and Jinja2. Everything else is a decision. Django gives you HTTP, routing, templates, an ORM, migrations, auth, sessions, forms, an admin panel, signals, and a CLI — all pre-wired. For an app that's content-heavy, admin-heavy, or "internal tool with a login screen", that's an enormous head start.

NeedFlask answerDjango answer
ORMAdd SQLAlchemyBuilt in
MigrationsAdd Alembicmakemigrations / migrate
AuthAdd Flask-Login + Flask-Bcryptdjango.contrib.auth
Admin UIBuild one/admin/ for free
Forms with CSRFAdd Flask-WTFdjango.forms + CSRF middleware
EmailAdd Flask-Maildjango.core.mail

Pick Django when:

  • You need an admin panel for non-technical users to manage data
  • The app is content-heavy — blogs, CMS-style sites, catalogues
  • You want one stack for the whole product, not a microservice constellation
  • You're shipping an MVP solo and don't want to re-invent auth or migrations

Pick something else when:

  • You're building a JSON-first API for a separate SPA — FastAPI is leaner
  • The app is genuinely tiny (a handful of routes) — Flask gets out of the way
  • Async-everything is core — Django supports async views but the ORM is still catching up

For the full comparison, see web-which-framework.


2. Project vs App — The Distinction That Trips Everyone Up

Django uses two words that sound interchangeable but aren't:

  • A project is the whole deployable thing — mysite/. One settings.py, one urls.py, one wsgi.py.
  • An app is a self-contained unit of functionality inside the project — blog/, users/, billing/. One concern per app.

A project has many apps. An app does one thing. The Django docs say it more bluntly: an app should be "reusable" — you should be able to drop it into another project. In practice most internal apps aren't reusable, but the discipline of "one app, one concern" is worth keeping.

bash
django-admin startproject mysite          # creates the project
cd mysite
python manage.py startapp blog            # creates an app
python manage.py startapp users           # another app, different concern

The result:

python
mysite/
├── manage.py                  # the CLI entrypoint — you run everything through this
├── mysite/                    # the project package
│   ├── __init__.py
│   ├── settings.py            # all configuration
│   ├── urls.py                # root URL routing
│   ├── wsgi.py                # WSGI entry for gunicorn
│   └── asgi.py                # ASGI entry for uvicorn (async)
└── blog/                      # an app
    ├── __init__.py
    ├── admin.py               # register models for the admin UI
    ├── apps.py                # app config
    ├── migrations/            # generated migration files — commit these
    ├── models.py              # ORM models
    ├── tests.py               # tests
    └── views.py               # view functions / classes

manage.py is your front door — runserver, migrate, shell, createsuperuser, test. You'll type python manage.py a hundred times a day.


3. The URL → View → Template Pipeline

A request to /articles/42/ flows through three pieces:

1. URLconf — urls.py matches the path and dispatches to a view function.
2. View — Python function or class that runs the logic and returns an HttpResponse.
3. Template — usually an HTML file rendered with context, returned as the response body.

python
# blog/urls.py
from django.urls import path
from . import views

urlpatterns = [
    path("articles/", views.article_list, name="article-list"),
    path("articles/<int:id>/", views.article_detail, name="article-detail"),
]

The <int:id> is a path converter — Django parses the segment as an int and passes it as a keyword argument. Other converters: str, slug, uuid, path. Get this right and your views receive properly typed values, no string-to-int dance.

python
# blog/views.py
from django.shortcuts import render, get_object_or_404
from .models import Article

def article_list(request):
    articles = Article.objects.filter(status="published").order_by("-created_at")
    return render(request, "blog/article_list.html", {"articles": articles})

def article_detail(request, id):
    article = get_object_or_404(Article, id=id, status="published")
    return render(request, "blog/article_detail.html", {"article": article})

render(request, template_name, context_dict) is the standard return — it loads the template, fills it with the context, and returns a 200 response. get_object_or_404 is the shortcut for "fetch this row or return a 404 if it's missing" — without it you'd write a four-line try/except every time.

Wire the app's URLs into the project's root URLconf:

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

urlpatterns = [
    path("admin/", admin.site.urls),
    path("blog/", include("blog.urls")),     # everything under /blog/ goes to blog.urls
]

include is the namespace mechanism — path("blog/", include("blog.urls")) means a request to /blog/articles/42/ hits article_detail(request, id=42).


4. Templates — Django Template Language

Django ships its own templating language (similar to Jinja2 but stricter). Two delimiters:

  • {{ variable }} — print a value
  • {% tag %} — control flow (if, for, block, url, etc.)
html
{# blog/templates/blog/article_list.html #}
{% extends "base.html" %}

{% block content %}
  <h1>Articles</h1>
  {% if articles %}
    <ul>
      {% for article in articles %}
        <li>
          <a href="{% url 'article-detail' id=article.id %}">{{ article.title }}</a>
          <small>{{ article.created_at|date:"Y-m-d" }}</small>
        </li>
      {% endfor %}
    </ul>
  {% else %}
    <p>No articles yet.</p>
  {% endif %}
{% endblock %}

Three patterns worth pointing out:

  • {% extends "base.html" %} plus {% block content %}...{% endblock %} is template inheritance. The base file defines blocks; child templates override them. One layout, many pages.
  • {% url 'article-detail' id=article.id %} reverses the URL by name (the name="article-detail" from the URLconf). If you change the URL pattern later, every template still resolves correctly. Never hard-code /articles/{{ article.id }}/ — you'll regret it the day you rename a route.
  • {{ value|filter:"arg" }} is the filter syntax. Built-ins include date, default, length, lower, truncatewords, escape (escapes by default — Django auto-escapes everything, which is why XSS is rare in well-written Django apps).

By convention, app templates live at blog/templates/blog/article_list.html — the duplicated blog/ prevents collisions when multiple apps have a article_list.html.


5. Static Files — CSS, JS, Images

Per-app static files live at blog/static/blog/main.css. Tell Django where to look:

python
# settings.py
STATIC_URL = "static/"
STATICFILES_DIRS = [BASE_DIR / "static"]      # project-level extras
STATIC_ROOT = BASE_DIR / "staticfiles"        # collectstatic target for production
+ setup added so this can run · defines BASE_DIR
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

BASE_DIR = _AutoMock('BASE_DIR')

Reference them in templates:

html
{% load static %}
<link rel="stylesheet" href="{% static 'blog/main.css' %}">

In development, runserver serves static files automatically. In production, you run python manage.py collectstatic to copy every app's static files into STATIC_ROOT, then point nginx (or your CDN) at that directory. Skipping collectstatic is one of the most common deployment bugs — the page loads but nothing is styled.


6. Function-Based vs Class-Based Views

Django has two view styles. They are equivalent in capability; pick by readability.

Function-based views (FBVs) — what we used above. Explicit, easy to read, easy to debug. Best for one-off logic.

Class-based views (CBVs) — Django ships generic CBVs (ListView, DetailView, CreateView, etc.) that encode CRUD patterns. You inherit and override.

python
from django.views.generic import ListView, DetailView
from .models import Article

class ArticleListView(ListView):
    model = Article
    template_name = "blog/article_list.html"
    context_object_name = "articles"
    paginate_by = 20
    queryset = Article.objects.filter(status="published").order_by("-created_at")

class ArticleDetailView(DetailView):
    model = Article
    template_name = "blog/article_detail.html"

Wire them with .as_view():

python
path("articles/", ArticleListView.as_view(), name="article-list"),
path("articles/<int:pk>/", ArticleDetailView.as_view(), name="article-detail"),
+ setup added so this can run · defines path, ArticleListView, ArticleDetailView
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

def path(*_a, **_kw):
    print('-> path() called')
    return _AutoMock('path()')
ArticleListView = _AutoMock('ArticleListView')
ArticleDetailView = _AutoMock('ArticleDetailView')

CBVs are concise when your view is "list these / show this / create one of these" with minor tweaks. They become opaque the moment your logic strays from the template — at which point an FBV is cleaner. Use whichever makes the code easier to read next year.


7. Settings — DEBUG, ALLOWED_HOSTS, and SECRET_KEY

mysite/settings.py is one big module of constants. The four that bite people in production:

python
import os
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent

SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]      # never commit this
DEBUG = os.environ.get("DJANGO_DEBUG") == "1"     # default False
ALLOWED_HOSTS = os.environ.get("DJANGO_HOSTS", "").split(",")

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "blog",                                       # your app — don't forget to register
    "users",
]

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": os.environ["DB_NAME"],
        "USER": os.environ["DB_USER"],
        "PASSWORD": os.environ["DB_PASSWORD"],
        "HOST": os.environ.get("DB_HOST", "localhost"),
        "PORT": os.environ.get("DB_PORT", "5432"),
    }
}
+ setup added so this can run · defines
import os  # noqa: F401
os.environ.setdefault("DJANGO_SECRET_KEY", "example-django-secret-key")
os.environ.setdefault("DB_NAME", "example-db-name")
os.environ.setdefault("DB_USER", "example-db-user")
os.environ.setdefault("DB_PASSWORD", "example-db-password")
os.environ.setdefault("DJANGO_DEBUG", "false")
os.environ.setdefault("DJANGO_HOSTS", "example-django-hosts")
os.environ.setdefault("DB_HOST", "example-db-host")
os.environ.setdefault("DB_PORT", "8000")

Four cardinal rules:

  • DEBUG=False in production. With DEBUG=True, Django renders a full stack trace including local variables and your SECRET_KEY on any unhandled error. It's a security hole big enough to drive a truck through.
  • ALLOWED_HOSTS must list your real domain when DEBUG=False. Anything not in the list returns a 400. The setting exists to prevent HTTP Host header attacks.
  • SECRET_KEY comes from an environment variable. Never commit it. Rotate it if it leaks (sessions will invalidate — that's the cost).
  • Read database credentials from env vars too. See envconfig for the canonical pattern.

For multi-environment setups (dev / staging / prod), the standard pattern is settings/base.py, settings/dev.py, settings/prod.py — each importing from base and overriding.


8. The Legendary Admin Site

This is the feature that sells Django. Register a model and you get a full CRUD UI — list, search, filter, edit, delete — for non-technical users, for free.

python
# blog/admin.py
from django.contrib import admin
from .models import Article

@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
    list_display = ("title", "status", "created_at")
    list_filter = ("status",)
    search_fields = ("title", "body")

Create a superuser and log in:

bash
python manage.py createsuperuser
python manage.py runserver
# visit http://localhost:8000/admin/

That's it. No forms code, no templates, no view logic — a working admin panel. For an internal tool managing 100 records, the admin is the product. For a public-facing site, it's the dashboard your support team uses every day. This is why a Django MVP ships faster than the equivalent Flask MVP: half the UI is already built.


9. Auth, Forms, and the Shell

Three more batteries worth naming:

Auth — django.contrib.auth provides User, login/logout views, password reset by email, and the @login_required decorator:

python
from django.contrib.auth.decorators import login_required

@login_required
def my_account(request):
    return render(request, "users/account.html", {"user": request.user})
+ setup added so this can run · defines render
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

def render(*_a, **_kw):
    print('-> render() called')
    return _AutoMock('render()')

Mount the auth URLs in your root URLconf and you have login, logout, and password reset working. For JWT-based APIs see auth-jwt.

Forms — django.forms.Form and forms.ModelForm give you validation, rendering, and CSRF protection in one. CSRF middleware is on by default; every POST template needs {% csrf_token %} inside the <form>.

The shell — python manage.py shell opens an interactive Python session with Django pre-loaded. The fastest way to poke at your ORM, debug a query, or seed data:

python
$ python manage.py shell
>>> from blog.models import Article
>>> Article.objects.count()
42
>>> Article.objects.filter(status="published").update(featured=True)

We dig into the ORM properly in the next lesson.


10. Running It — Dev vs Production

For development:

bash
python manage.py runserver                  # http://127.0.0.1:8000/

runserver is a single-threaded dev server. It auto-reloads on code changes, prints stack traces, and serves static files — none of which you want in production.

For production: gunicorn mysite.wsgi:application (sync WSGI) or uvicorn mysite.asgi:application (ASGI, for async views and channels). Both behind nginx or a managed load balancer. Never run runserver in production — it's a security and performance non-starter.

A typical deploy script:

bash
python manage.py migrate                    # apply pending schema changes
python manage.py collectstatic --noinput    # gather static files
gunicorn mysite.wsgi:application --workers 4

We cover the full deploy story in deployment.


11. Common Mistakes

1. DEBUG=True in production. Renders stack traces with secrets to the world. Always sourced from an env var, defaulting to False. Test the deploy by intentionally raising and confirming you see "Server Error (500)", not a traceback.

2. Empty or wildcard ALLOWED_HOSTS. ALLOWED_HOSTS = ["*"] is the lazy fix and a Host-header-attack invitation. List your actual domains.

3. Running runserver in production. It's single-threaded, not battle-tested, and explicitly documented as dev-only. Use gunicorn or uvicorn.

4. Committing SECRET_KEY. If it ends up in git, sessions and signed cookies are forgeable. Rotate it and force re-login for everyone.

5. Forgetting collectstatic on deploy. The page loads, nothing is styled, you spend an hour debugging. Add it to your deploy script and forget about it.

6. Forgetting to add an app to INSTALLED_APPS. Symptom: makemigrations doesn't see your model, the admin doesn't pick it up, templates can't be found. Every new app needs to be registered.

7. Hard-coding URLs in templates. <a href="/articles/{{ a.id }}/"> works today and breaks when you rename the route. Use {% url 'article-detail' id=a.id %}.


🎯 Your Turn — Scaffold a Tasks App

Build a Django project with a single app that lists tasks. The skeleton walks you through it; the model is given.

Requirements:

1. Create a project todo and an app tasks.
2. Register tasks in INSTALLED_APPS.
3. Define a Task model with title, done, and created_at.
4. Run makemigrations and migrate.
5. Write a function-based view task_list(request) that fetches all tasks ordered newest-first.
6. Wire the view to /tasks/ via the app's urls.py and include it from the project's urls.py.
7. Render a template that loops over the tasks.

Skeleton:

bash
django-admin startproject todo
cd todo
python manage.py startapp tasks
python
# tasks/models.py
from django.db import models

class Task(models.Model):
    title = models.CharField(max_length=200)
    done = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["-created_at"]

    def __str__(self):
        return self.title
python
# tasks/views.py
from django.shortcuts import render
from .models import Task

def task_list(request):
    # TODO 1: fetch all tasks (model Meta already orders them)
    # TODO 2: return render(request, "tasks/task_list.html", {"tasks": tasks})
    ...
python
# tasks/urls.py — you create this file
from django.urls import path
from . import views

urlpatterns = [
    # TODO 3: path("", views.task_list, name="task-list"),
]
python
# todo/urls.py
from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path("admin/", admin.site.urls),
    # TODO 4: include tasks.urls under "tasks/"
]
html
{# tasks/templates/tasks/task_list.html — you create this file #}
<h1>Tasks</h1>
<ul>
  {# TODO 5: for-loop over tasks, mark done ones with a strike-through #}
</ul>
Hint 1 — Settings registration Open todo/settings.py and add "tasks" to the INSTALLED_APPS list. Without it, makemigrations won't see tasks/models.py and the admin won't pick up the model.
Hint 2 — The template directory By convention the file lives at tasks/templates/tasks/task_list.html — the duplicated tasks/ folder is what lets Django distinguish your template from another app's task_list.html.
Show full solution
python
# tasks/views.py
from django.shortcuts import render
from .models import Task

def task_list(request):
    tasks = Task.objects.all()                  # Meta.ordering handles "-created_at"
    return render(request, "tasks/task_list.html", {"tasks": tasks})
python
# tasks/urls.py
from django.urls import path
from . import views

urlpatterns = [
    path("", views.task_list, name="task-list"),
]
python
# todo/urls.py
from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path("admin/", admin.site.urls),
    path("tasks/", include("tasks.urls")),
]
python
# todo/settings.py — the relevant change
INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "tasks",                                    # add this
]
html
{# tasks/templates/tasks/task_list.html #}
<!doctype html>
<html>
<head><meta charset="utf-8"><title>Tasks</title></head>
<body>
  <h1>Tasks</h1>
  {% if tasks %}
    <ul>
      {% for task in tasks %}
        <li>
          {% if task.done %}
            <s>{{ task.title }}</s>
          {% else %}
            {{ task.title }}
          {% endif %}
          <small>{{ task.created_at|date:"Y-m-d H:i" }}</small>
        </li>
      {% endfor %}
    </ul>
  {% else %}
    <p>No tasks yet — add some via the admin.</p>
  {% endif %}
</body>
</html>

Then run:

bash
python manage.py makemigrations tasks
python manage.py migrate
python manage.py createsuperuser
python manage.py runserver

Register the model in tasks/admin.py (admin.site.register(Task)), log in to /admin/, add a couple of tasks, then visit /tasks/. Five files, one migration, and you've got a working data-driven page with a free admin UI sitting next to it. That's the Django proposition in one exercise.


What You Learned

  • Django is batteries-included — ORM, admin, auth, migrations, forms, all bundled. Trade choice for velocity.
  • Project vs app — a project has many apps; an app is one concern (blog, users, billing).
  • URL → view → template — urls.py matches a path, the view runs logic, render(request, template, context) returns the page.
  • Function-based views are explicit and readable; class-based views are concise when the logic fits a generic pattern.
  • Django Template Language — {{ var }}, {% for %}, template inheritance with {% extends %} + {% block %}, and {% url 'name' %} for safe reverse-routing.
  • Settings — DEBUG=False in prod, ALLOWED_HOSTS listed, SECRET_KEY from env, every app in INSTALLED_APPS.
  • The admin — register a model in admin.py and get a full CRUD UI for free. The killer feature.
  • runserver is dev-only — use gunicorn or uvicorn in production, and don't forget collectstatic.

Next: Django Models & ORM — fields, querysets, migrations, the N+1 problem, and how to make the Django ORM sing.