Django: Building Python Web Apps
You start a small Flask project, and a month later you've wired up SQLAlchemy, Alembic, Flask-Login, a password reset flow, WTForms, and a half-finished admin page so someone can fix typos in the database. None of that was the actual product. Django exists for exactly this situation: those pieces ship with the framework, they're already wired together, and they've been used in production for close to two decades.
This post walks through a small blog app from scratch: project setup, models and migrations, views and URLs, templates, the admin, auth, and a production setup with Gunicorn. I'll also point out a few things that tend to bite people the first time they deploy.
Flask and FastAPI give you full control, but you pick and glue every piece yourself. Django comes with an ORM, admin, auth, forms, migrations, and security middleware that already know about each other. For a CRUD-heavy app with a database at the center, that usually means a lot less setup code before you get to real features.
The MVT Architecture
Django calls its pattern Model-View-Template (MVT). If you know MVC, the mapping is a bit confusing at first: Django's "view" is what most frameworks call a controller, and the framework itself (URL routing plus request handling) plays the controller role. Once that clicks, the rest of the layout makes sense.
- Model: Defines your data structure and handles all database interactions via Django's ORM. One Python class = one database table.
- View: Contains your business logic. Views receive HTTP requests, query the database through models, and return HTTP responses.
- Template: HTML files with Django's templating language for rendering dynamic content. Templates receive context data from views and produce the final HTML.
Django's MVT architecture: URL Router dispatches requests to Views, which interact with Models and render Templates
Installation and Project Setup
Django requires Python 3.10 or higher. Use a virtual environment per project so one project's Django version doesn't break another's.
| Requirement | Minimum | Recommended |
|---|---|---|
| Python | 3.10 | 3.12+ |
| Django | 4.2 (LTS) | 5.1+ |
| Database | SQLite (dev only) | PostgreSQL 14+ |
| pip | 22.0 | Latest |
Create a Virtual Environment
Isolate your project dependencies using Python's built-in venv module or uv for faster installs.
# Create and activate virtual environment
python -m venv venv
source venv/bin/activate # Linux/macOS
# venv\Scripts\activate # Windows
# Install Django
pip install django
# Verify installation
python -m django --version # Should print 5.x.x
Create a Django Project
The startproject command scaffolds the top-level configuration files for your Django site.
# Create the project (trailing dot avoids a nested folder)
django-admin startproject mysite .
# Run the development server
python manage.py runserver
# → http://127.0.0.1:8000/
Create Your First App
Django projects are composed of reusable apps. Each app has its own models, views, and URLs, which keeps the codebase split into manageable pieces.
# Create a "blog" app inside your project
python manage.py startapp blog
Project Structure Explained
After running these commands, your project layout looks like this. It's worth knowing what each file does before you start adding code:
mysite/ ← project config package
__init__.py
settings.py ← all project settings (DB, INSTALLED_APPS, etc.)
urls.py ← root URL configuration
wsgi.py ← WSGI entry point for production servers
asgi.py ← ASGI entry point for async servers
blog/ ← your first app
migrations/ ← auto-generated database migration files
__init__.py
admin.py ← register models with Django admin
apps.py ← app configuration class
models.py ← database model definitions
tests.py ← unit and integration tests
views.py ← request handlers (business logic)
urls.py ← (you create this) app-level URL routing
manage.py ← CLI tool for running dev server, migrations, etc.
Register the App in settings.py
Django won't discover your new app until you add it to INSTALLED_APPS in mysite/settings.py.
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'blog', # ← add your app here
]
Models and the ORM
You describe your schema as Python classes and the ORM handles the SQL. Each subclass of models.Model becomes a table, and each field attribute becomes a column. For normal CRUD you won't write SQL by hand.
Defining Models
from django.db import models
from django.contrib.auth.models import User
from django.utils import timezone
class Category(models.Model):
name = models.CharField(max_length=100)
slug = models.SlugField(unique=True)
class Meta:
verbose_name_plural = "categories"
def __str__(self):
return self.name
class Post(models.Model):
STATUS_CHOICES = [
('draft', 'Draft'),
('published', 'Published'),
]
title = models.CharField(max_length=250)
slug = models.SlugField(max_length=250, unique_for_date='publish')
author = models.ForeignKey(User, on_delete=models.CASCADE, related_name='posts')
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True)
body = models.TextField()
publish = models.DateTimeField(default=timezone.now)
created = models.DateTimeField(auto_now_add=True) # set once on creation
updated = models.DateTimeField(auto_now=True) # updated on every save
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='draft')
class Meta:
ordering = ['-publish']
indexes = [models.Index(fields=['-publish'])]
def __str__(self):
return self.title
Running Migrations
Think of migrations as version control for your schema. Every time you change models.py, you run two commands. Commit the generated migration files with your code; your teammates and your server need them too.
# Generate migration file from model changes
python manage.py makemigrations
# Apply pending migrations to the database
python manage.py migrate
# Inspect the SQL Django will run (without executing it)
python manage.py sqlmigrate blog 0001
Querying with the ORM
from blog.models import Post
from django.contrib.auth.models import User
# Retrieve all published posts (QuerySet is lazy, so no DB hit yet)
published = Post.objects.filter(status='published')
# Chaining filters (AND logic)
recent = Post.objects.filter(
status='published',
publish__year=2026
).order_by('-publish')
# OR logic using Q objects
from django.db.models import Q
results = Post.objects.filter(
Q(title__icontains='django') | Q(body__icontains='django')
)
# Select related (uses a JOIN and avoids N+1 queries)
posts = Post.objects.select_related('author', 'category').all()
# Aggregate: count posts per category
from django.db.models import Count
Post.objects.values('category__name').annotate(total=Count('id'))
# Create a new post
user = User.objects.get(username='admin')
post = Post.objects.create(
title='My First Post',
slug='my-first-post',
author=user,
body='Hello, Django!',
status='published',
)
# Update
Post.objects.filter(author=user).update(status='published')
# Delete
Post.objects.filter(status='draft', publish__lt=timezone.now() - timedelta(days=30)).delete()
The most common Django performance problem is touching a related object inside a loop, like {{ post.author.username }} in a template, which fires one query per row. Use select_related() for ForeignKey/OneToOne (a SQL JOIN) and prefetch_related() for ManyToMany and reverse FK relations (one extra query for the whole set). Django Debug Toolbar shows the query count per page, so check it before guessing. Also note the snippet above uses timezone and timedelta without importing them; in a real shell you'd need from django.utils import timezone and from datetime import timedelta first.
Views and URL Routing
A view takes an HttpRequest and returns an HttpResponse. It can be a plain function or a class. The patterns in urls.py decide which view handles which path, using path converters like <int:year> (or regex via re_path if you really need it).
Function-Based Views (FBVs)
from django.shortcuts import render, get_object_or_404
from django.core.paginator import Paginator
from .models import Post
def post_list(request):
posts = Post.objects.filter(status='published')
paginator = Paginator(posts, 10) # 10 posts per page
page_number = request.GET.get('page', 1)
page_obj = paginator.get_page(page_number)
return render(request, 'blog/post_list.html', {'page_obj': page_obj})
def post_detail(request, year, month, day, slug):
post = get_object_or_404(
Post,
status='published',
slug=slug,
publish__year=year,
publish__month=month,
publish__day=day,
)
return render(request, 'blog/post_detail.html', {'post': post})
Class-Based Views (CBVs)
For list pages, detail pages, and forms, the generic class-based views do the pagination, context, and 404 handling for you. The trade-off is that the logic is spread across mixins, so when something behaves oddly you end up reading Django's source (or ccbv.co.uk) to find which method to override. Here's the same pair of views:
from django.views.generic import ListView, DetailView
from .models import Post
class PostListView(ListView):
model = Post
queryset = Post.objects.filter(status='published')
context_object_name = 'posts'
paginate_by = 10
template_name = 'blog/post_list.html'
class PostDetailView(DetailView):
model = Post
context_object_name = 'post'
template_name = 'blog/post_detail.html'
def get_queryset(self):
return Post.objects.filter(status='published')
URL Configuration
from django.urls import path
from . import views
app_name = 'blog' # namespace for URL reversing
urlpatterns = [
path('', views.PostListView.as_view(), name='post_list'),
path(
'<int:year>/<int:month>/<int:day>/<slug:slug>/',
views.PostDetailView.as_view(),
name='post_detail',
),
]
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('blog/', include('blog.urls', namespace='blog')),
]
Django Templates
Django's template language is deliberately limited. You can't call arbitrary Python in it, which pushes logic back into views where it belongs. The feature you'll use most is template inheritance: write the base layout once, then have each page extend it and fill in blocks.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{% block title %}My Blog{% endblock %}</title>
<link rel="stylesheet" href="{% static 'css/styles.css' %}">
{% load static %}
</head>
<body>
<nav>...</nav>
<main>
{% block content %}{% endblock %}
</main>
<footer>...</footer>
</body>
</html>
In the base template above, {% load static %} sits after the first {% static %} tag. If you copy it as is, Django raises TemplateSyntaxError: Invalid block tag on line 5: 'static'. Did you forget to register or load this tag? Put {% load static %} at the very top of the file (and in every template that uses {% static %}, since loads aren't inherited through {% extends %}).
{% extends 'base.html' %}
{% block title %}Latest Posts | My Blog{% endblock %}
{% block content %}
<h1>Latest Posts</h1>
{% for post in page_obj %}
<article>
<h2>
<a href="{{ post.get_absolute_url }}">{{ post.title }}</a>
</h2>
<p>By {{ post.author.get_full_name }} on {{ post.publish|date:"F j, Y" }}</p>
<p>{{ post.body|truncatewords:30 }}</p>
</article>
{% empty %}
<p>No posts published yet.</p>
{% endfor %}
{# Pagination #}
{% if page_obj.has_other_pages %}
<nav>
{% if page_obj.has_previous %}
<a href="?page={{ page_obj.previous_page_number }}">Previous</a>
{% endif %}
<span>Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}">Next</a>
{% endif %}
</nav>
{% endif %}
{% endblock %}
Django Admin Interface
Register a model and you get a working CRUD screen for it, with search, filters, and pagination, without writing any frontend code. It's fine for internal staff and for poking at data in production. I wouldn't hand it to customers, though: it's built around your database tables, not around your users' workflows.
from django.contrib import admin
from .models import Category, Post
@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
list_display = ['name', 'slug']
prepopulated_fields = {'slug': ('name',)}
@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
list_display = ['title', 'author', 'status', 'publish']
list_filter = ['status', 'created', 'publish', 'author']
search_fields = ['title', 'body']
prepopulated_fields = {'slug': ('title',)}
raw_id_fields = ['author'] # search widget instead of dropdown
date_hierarchy = 'publish'
ordering = ['status', '-publish']
list_editable = ['status'] # edit status inline in the list view
python manage.py createsuperuser
# → Visit http://127.0.0.1:8000/admin/ to log in
Authentication and Security
You get user accounts, password hashing (PBKDF2 by default), login/logout views, email password reset, and permissions out of the box. For plain username/password auth you usually don't need anything else. One thing to decide on day one: if there's any chance you'll want email login or extra user fields, set up a custom user model (AUTH_USER_MODEL) before your first migration. Switching later, once tables reference auth.User, is painful.
from django.contrib.auth import views as auth_views
urlpatterns += [
# Built-in login/logout/password reset views
path('accounts/', include('django.contrib.auth.urls')),
]
# Provides: /accounts/login/ /accounts/logout/
# /accounts/password_change/ /accounts/password_reset/
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
# Function-based view protection
@login_required
def create_post(request):
...
# Class-based view protection
class CreatePostView(LoginRequiredMixin, CreateView):
model = Post
fields = ['title', 'body', 'status']
# LoginRequiredMixin redirects unauthenticated users to settings.LOGIN_URL
Before deploying, set DEBUG = False in settings, configure ALLOWED_HOSTS, use environment variables for SECRET_KEY and database credentials (never commit them to git), serve static files via a CDN or Nginx, and enable HTTPS. Run python manage.py check --deploy to get Django's built-in security audit.
Django's Built-in Security Features
- CSRF Protection: Every POST form needs a
{% csrf_token %}tag, and the middleware checks it for you. Forget the tag and you get a 403 "CSRF verification failed" page. - SQL Injection Protection: The ORM parameterizes its queries. If you use
raw(), pass user input through theparamsargument instead of formatting it into the SQL string. - XSS Prevention: The template engine auto-escapes HTML special characters. Use
{{ value|safe }}only when you trust the source. - Clickjacking Protection: The
X-Frame-Options: DENYheader is sent by default viaXFrameOptionsMiddleware. - Secure Cookies: Set
SESSION_COOKIE_SECURE = TrueandCSRF_COOKIE_SECURE = Truein production to restrict cookies to HTTPS.
Deploying a Django Application
Django ships with a development server (runserver) that is not suitable for production. The standard production stack uses Gunicorn as the WSGI application server behind Nginx as a reverse proxy.
| Component | Role | Alternative |
|---|---|---|
| Gunicorn | WSGI/ASGI server that runs your Python code | uWSGI, Daphne (async) |
| Nginx | Reverse proxy, static files, SSL termination | Caddy, Apache |
| PostgreSQL | Production database | MySQL, MariaDB |
| Redis | Cache backend, session storage, Celery broker | Memcached |
| Celery | Asynchronous task queue | Django Q, Dramatiq |
pip install gunicorn psycopg2-binary whitenoise
# Collect static files into STATIC_ROOT
python manage.py collectstatic
# Run with 4 worker processes
gunicorn mysite.wsgi:application --workers 4 --bind 0.0.0.0:8000
import os
SECRET_KEY = os.environ['DJANGO_SECRET_KEY'] # never hardcode!
DEBUG = False
ALLOWED_HOSTS = ['yourdomain.com', 'www.yourdomain.com']
# PostgreSQL
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': '5432',
}
}
# WhiteNoise serves static files efficiently without Nginx
MIDDLEWARE = ['whitenoise.middleware.WhiteNoiseMiddleware', ...rest...]
STATIC_ROOT = BASE_DIR / 'staticfiles'
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
# Redis cache
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
'LOCATION': os.environ.get('REDIS_URL', 'redis://127.0.0.1:6379/1'),
}
}
# Security headers
SECURE_SSL_REDIRECT = True
SECURE_HSTS_SECONDS = 31536000
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
If the domain you're serving from isn't in ALLOWED_HOSTS, every request returns a 400 and the log shows DisallowedHost: Invalid HTTP_HOST header. That includes the IP address if you test with it directly. Second, STATICFILES_STORAGE was deprecated in Django 4.2 and removed in 5.1. On 5.1+ the setting above is ignored, so configure WhiteNoise through the STORAGES dict instead ("staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"}).
When I'd Use Django (and When I Wouldn't)
I'd reach for Django when the app is mostly a database with screens and business rules around it: a CMS, an internal tool, a marketplace back office, a SaaS dashboard. The admin alone saves a lot of work when non-developers need to edit data, and the auth and migrations are things you don't want to rebuild yourself.
I'd think twice in a few cases:
- A small JSON API or a single microservice. FastAPI or Flask will be lighter, and you'll ignore most of what Django ships. If you do pick Django for an API, you'll almost certainly add Django REST Framework or django-ninja on top.
- Heavy async or real-time work. Django supports async views, but the ORM is still mostly sync under the hood, and WebSockets need Channels plus a separate ASGI server. If the app is mainly long-lived connections, a framework built around async from the start is less friction.
- Teams that dislike conventions. Django has opinions about project layout, settings, and how apps fit together. Fighting those costs more than using a smaller framework.
If you do go with Django, the things I'd check before production are the ones from this post that are easy to skip: run manage.py check --deploy, look at query counts on your list pages, pick a custom user model early, and keep secrets out of settings.py. For next steps, DRF (APIs for a React or mobile frontend), Celery (background jobs), and django-allauth (social login) are the add-ons most projects end up needing.