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 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 djangoandpython 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.
| 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/ included |
| 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 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/. 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 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.
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:
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.
# 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.
# 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:
# 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.)
{# 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 (thename="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 includedate,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:
# 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:
{% 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.
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. 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:
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=Falsein production. WithDEBUG=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_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. 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.
# 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 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:
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 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:
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:
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:
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 second tasks/ folder 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 (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 ]
{# 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:
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.pymatches 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=Falsein prod,ALLOWED_HOSTSlisted,SECRET_KEYfrom env, every app inINSTALLED_APPS. - The admin: register a model in
admin.pyand get a full CRUD UI without writing it. This is Django's best-known feature. runserveris dev-only: usegunicornoruvicornin production, and don't forgetcollectstatic.
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.