Django: Batteries-Included Web
1 · The lesson
readWhere 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 djangoandpython 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.
| Need | Flask answer | Django answer |
|---|---|---|
| ORM | Add SQLAlchemy | Built in |
| Migrations | Add Alembic | makemigrations / migrate |
| Auth | Add Flask-Login + Flask-Bcrypt | django.contrib.auth |
| Admin UI | Build one | /admin/ for free |
| Forms with CSRF | Add Flask-WTF | django.forms + CSRF middleware |
| Add Flask-Mail | django.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/. Onesettings.py, oneurls.py, onewsgi.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.
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:
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.
# 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.
# 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:
# 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.)
{# 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 (thename="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 includedate,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:
# 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:
{% 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.
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():
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:
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=Falsein production. WithDEBUG=True, Django renders a full stack trace including local variables and yourSECRET_KEYon any unhandled error. It's a security hole big enough to drive a truck through.ALLOWED_HOSTSmust list your real domain whenDEBUG=False. Anything not in the list returns a 400. The setting exists to prevent HTTP Host header attacks.SECRET_KEYcomes 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.
# 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:
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:
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 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:
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:
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:
django-admin startproject todo cd todo python manage.py startapp tasks
# 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
# 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}) ...
# 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"), ]
# 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/" ]
{# 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
Opentodo/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 attasks/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
# 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})
# tasks/urls.py from django.urls import path from . import views urlpatterns = [ path("", views.task_list, name="task-list"), ]
# 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")), ]
# 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 ]
{# 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:
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.pymatches 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=Falsein prod,ALLOWED_HOSTSlisted,SECRET_KEYfrom env, every app inINSTALLED_APPS. - The admin — register a model in
admin.pyand get a full CRUD UI for free. The killer feature. runserveris dev-only — usegunicornoruvicornin production, and don't forgetcollectstatic.
Next: Django Models & ORM — fields, querysets, migrations, the N+1 problem, and how to make the Django ORM sing.