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 chosen for you, and we've been doing it for about twenty years". You get an ORM, a templating engine, an auth system, a forms framework, a migration tool and, most famously, a fully working admin UI, all from one pip install. The cost is that you follow Django's conventions. There is a Django way to do most things, and working against it wastes time.

This lesson walks through the project layout, the URL-to-view-to-template pipeline, the settings, and Django's well-known 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, because it needs a real Python interpreter listening on a socket. Expected output is shown in comments.


1. Why Django: When Built-In Features Beat Choice

Flask gives you HTTP, routing, and Jinja2. For everything else, you choose a library. Django gives you HTTP, routing, templates, an ORM, migrations, auth, sessions, forms, an admin panel, signals and a CLI, all already connected. For an app with a lot of content, a lot of admin work, or an "internal tool with a login screen", this saves a lot of time at the start.

NeedFlask answerDjango answer
ORMAdd SQLAlchemyBuilt in
MigrationsAdd Alembicmakemigrations / migrate
AuthAdd Flask-Login + Flask-Bcryptdjango.contrib.auth
Admin UIBuild one/admin/ included
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 has a lot of content: blogs, CMS-style sites, catalogues
  • You want one stack for the whole product, not many separate microservices
  • You're building an MVP alone and don't want to write auth or migrations yourself

Pick something else when:

  • You're building a JSON-first API for a separate SPA: FastAPI is lighter
  • The app is really small (a few routes): Flask needs less setup
  • The whole app must be async: Django supports async views, but its ORM's async support is still incomplete

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


2. Project vs App: A Distinction That Confuses Many People

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

  • A project is the whole thing you deploy: 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 go further: an app should be "reusable", so you can add it to another project. In practice, most internal apps aren't reusable, but the rule "one app, one concern" is still worth following.

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, for a different part of the site

The result:

python
mysite/
├── manage.py                  # the command-line entry point (you run everything through it)
├── 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 them to Git)
    ├── models.py              # ORM models
    ├── tests.py               # tests
    └── views.py               # view functions / classes

manage.py is how you run Django's commands: runserver, migrate, shell, createsuperuser, test. You'll type python manage.py very often.


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: a 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. With a converter, your views receive values of the right type, so you don't have to convert strings to ints yourself.

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 value. 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 connects the app's URLs to the project. 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 to notice:

  • {% extends "base.html" %} plus {% block content %}...{% endblock %} is template inheritance. The base file defines blocks, and 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 }}/, because it will break when you rename the route.
  • {{ value|filter:"arg" }} is the filter syntax. Built-ins include date, default, length, lower, truncatewords, escape (Django escapes all output by default, which is why XSS is rare in well-written Django apps).

By convention, app templates live at blog/templates/blog/article_list.html. The second blog/ folder prevents name clashes when several apps have an 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"        # where collectstatic copies files 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. Forgetting 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. Both can do the same things, so pick the one that is easier to read.

Function-based views (FBVs): what we used above. They are explicit, easy to read and easy to debug. They are best for one-off logic.

Class-based views (CBVs): Django includes generic CBVs (ListView, DetailView, CreateView, etc.) for common CRUD patterns. You inherit from them and override what you need.

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. When your logic is different from the generic pattern, they become hard to follow, and then an FBV is cleaner. Use whichever makes the code easier to read a year from now.


7. Settings: DEBUG, ALLOWED_HOSTS, and SECRET_KEY

mysite/settings.py is one big module of constants. These four cause the most problems 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 (do not forget to register it here)
    "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")

Four important rules:

  • DEBUG=False in production. With DEBUG=True, Django shows a full stack trace on any unhandled error, including local variables and many of your settings. This is a serious security hole.
  • 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. Replace it if it leaks (this logs out all users, because their sessions become invalid).
  • Read database credentials from env vars too. See envconfig for the standard pattern.

For multi-environment setups (dev / staging / prod), the standard pattern is settings/base.py, settings/dev.py, settings/prod.py. Each one imports from base and overrides what it needs.


8. The Admin Site

This is the feature that makes many people choose Django. Register a model and you get a full CRUD UI (list, search, filter, edit, delete) for non-technical users, without writing it yourself.

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 all. With no forms code, no templates and no view logic, you have a working admin panel. For an internal tool that manages 100 records, the admin can be the whole product. For a public-facing site, it's the dashboard your support team uses every day. This is why a Django MVP is often finished faster than the same MVP in Flask: a large part of the UI is already built.


9. Auth, Forms, and the Shell

Three more built-in features:

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, so every POST template needs {% csrf_token %} inside the <form>.

The shell: python manage.py shell opens an interactive Python session with Django already loaded. It is the fastest way to try the ORM, debug a query or add test data:

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

We cover the ORM in detail 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 development server. It reloads on code changes, prints stack traces and serves static files. You want none of these in production.

For production: gunicorn mysite.wsgi:application (sync WSGI) or uvicorn mysite.asgi:application (ASGI, for async views and channels). Put both behind nginx or a managed load balancer. Never run runserver in production. It is not built for the security or performance a production server needs.

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 deployment in full in deployment.


11. Common Mistakes

1. DEBUG=True in production. It shows stack traces with secrets to anyone. Always read it from an env var, with False as the default. Test the deploy by raising an error on purpose and checking that you see "Server Error (500)", not a traceback.

2. Empty or wildcard ALLOWED_HOSTS. ALLOWED_HOSTS = ["*"] is a quick fix that leaves you open to Host header attacks. List your actual domains.

3. Running runserver in production. It is not tested for production use, and the Django docs say clearly that it is for development only. Use gunicorn or uvicorn.

4. Committing SECRET_KEY. If it ends up in git, anyone with the key can forge sessions and signed cookies. Replace it, and make everyone log in again.

5. Forgetting collectstatic on deploy. The page loads, but nothing is styled. Add it to your deploy script so it always runs.

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, but 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 shows you the steps, and 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 second tasks/ folder 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 (only the part that changes)
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 in 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/. With five files and one migration, you have a working page that reads from the database, plus an admin UI you didn't have to write. This exercise shows what Django offers.


What You Learned

  • Django is batteries-included: ORM, admin, auth, migrations and forms all come with it. You get less choice, but you build faster.
  • Project vs app: a project has many apps, and 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, and 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 without writing it. This is Django's best-known 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 use the Django ORM well.

Found this useful? Share it with a friend who’s learning Python.