django
Django is the batteries-included Python web framework: ORM with migrations, URL routing, templates, forms, sessions, authentication, an auto-generated admin interface, caching, and a security layer (CSRF, XSS escaping, SQL injection protection) all ship in the box and are designed to work together. You define models once and get database schema, admin CRUD screens, and form validation from the same definitions. It has been maintained since 2005 by the Django Software Foundation with a predictable release schedule and LTS versions. Version 6.0 requires Python 3.12+ and continues the gradual buildout of async views and background task hooks on top of a fundamentally sync-first core.
Still the best default for database-backed web products in Python; the admin plus ORM plus auth combination remains unmatched. Choose FastAPI instead when the product is primarily an async API.
Use it if
- You are building a database-backed product (SaaS, marketplace, internal tools) and want auth, admin, ORM, and migrations working together on day one
- The auto-generated admin alone would save you weeks; for content and ops teams it is a free back office
- You value boring stability: documented deprecation policy, LTS releases, and upgrade paths that thousands of teams walk every cycle
- You are hiring; Django developers and battle-tested packages (django-rest-framework, allauth, celery integrations) are easy to find
- You are building a small API or microservice; FastAPI or Flask get you there with a fraction of the framework to carry and native async throughout
- Your workload is heavily async (websockets everywhere, thousands of concurrent long-lived connections); Django's async support is real but bolted onto a sync core, and the ORM's async story still runs sync code under the hood
- You want to pick your own ORM, template engine, or auth; Django's parts are integrated, and swapping them out means fighting the framework
- You need the newest Python-typing-driven developer experience; Django's APIs predate type hints and editor autocomplete is weaker than FastAPI's without plugins
- Your frontend is a separate SPA and you only need JSON endpoints; the template/forms/admin half of Django becomes dead weight
Setup reality
pip install django is painless (two direct dependencies: asgiref and sqlparse), and django-admin startproject gives you a running site in minutes, but note 6.0 requires Python 3.12 or newer. The honest cost comes later: settings.py grows unwieldy without a pattern for env-based config, static files in production confuse everyone the first time (collectstatic, STORAGES, WhiteNoise or a CDN), and deployment means picking an ASGI/WSGI server (gunicorn, uvicorn) plus a database driver, none of which the tutorial covers deeply. The project layout (project vs app distinction) also puzzles newcomers. Migrations are excellent but merge conflicts on migration files are a recurring team annoyance.
Patterns
Start a project and appproject-setup
python -m pip install django
django-admin startproject mysite
cd mysite
python manage.py startapp blog
python manage.py runserverRegister the new app in INSTALLED_APPS in settings.py or its models and templates are invisible to Django.
Define a modeldefine-model
from django.db import models
class Post(models.Model):
title = models.CharField(max_length=200)
body = models.TextField(blank=True)
author = models.ForeignKey(
"auth.User", on_delete=models.CASCADE, related_name="posts"
)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.titleon_delete is mandatory on ForeignKey; auto_now_add is set once at insert and cannot be edited in admin.
Create and apply migrationsmigrations
python manage.py makemigrations
python manage.py migrate
python manage.py showmigrationsCommit migration files to git; two branches adding migrations to the same app need makemigrations --merge afterwards.
Query with the ORMorm-queries
from blog.models import Post
recent = (
Post.objects.filter(title__icontains="django")
.select_related("author")
.order_by("-created_at")[:10]
)
count = Post.objects.filter(author__username="ada").count()QuerySets are lazy and cached after first evaluation; select_related prevents the N+1 query on author access.
Wire a view to a URLview-and-url
# blog/views.py
from django.shortcuts import render, get_object_or_404
from .models import Post
def post_detail(request, pk):
post = get_object_or_404(Post, pk=pk)
return render(request, "blog/detail.html", {"post": post})
# mysite/urls.py
from django.urls import path
from blog import views
urlpatterns = [
path("posts/<int:pk>/", views.post_detail, name="post_detail"),
]get_object_or_404 turns a missing row into a 404 instead of an unhandled DoesNotExist exception.
Render data in a templatetemplate-render
{# blog/templates/blog/detail.html #}
<h1>{{ post.title }}</h1>
<p>by {{ post.author.username }} on {{ post.created_at|date:"Y-m-d" }}</p>
{% if post.body %}
<div>{{ post.body|linebreaks }}</div>
{% endif %}Templates auto-escape HTML by default; only use the safe filter on content you fully control.
Validate input with a ModelFormmodel-form
from django import forms
from .models import Post
class PostForm(forms.ModelForm):
class Meta:
model = Post
fields = ["title", "body"]
# in a view
form = PostForm(request.POST or None)
if request.method == "POST" and form.is_valid():
post = form.save(commit=False)
post.author = request.user
post.save()commit=False lets you set fields the form does not expose before the row is written.
Get a free admin interfaceadmin-register
# blog/admin.py
from django.contrib import admin
from .models import Post
@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
list_display = ("title", "author", "created_at")
search_fields = ("title",)
list_filter = ("created_at",)
# then: python manage.py createsuperuserThe admin is for trusted staff, not end users; putting customers in it is a common and regrettable shortcut.
Require login for a viewauth-protect-view
from django.contrib.auth.decorators import login_required
@login_required
def dashboard(request):
return render(request, "dashboard.html", {"user": request.user})Unauthenticated users are redirected to settings.LOGIN_URL, which you almost always need to set explicitly.
List page with a class-based viewclass-based-view
from django.views.generic import ListView
from .models import Post
class PostListView(ListView):
model = Post
paginate_by = 20
ordering = "-created_at"
# urls.py
path("posts/", PostListView.as_view(), name="post_list")The default template name is blog/post_list.html and the context variable is object_list (or post_list); both trip up beginners.
Set a custom user model on day onecustom-user-model
# accounts/models.py
from django.contrib.auth.models import AbstractUser
class User(AbstractUser):
pass
# settings.py
AUTH_USER_MODEL = "accounts.User"Do this before the first migrate; switching user models on a live database later is genuinely painful.
Read settings from the environmentsettings-env
# settings.py
import os
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
DEBUG = os.environ.get("DJANGO_DEBUG", "") == "1"
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "").split(",")DEBUG=True in production leaks stack traces and settings; ALLOWED_HOSTS must be set once DEBUG is off or every request 400s.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| fastapi | PyPI | You are building an API-first or async-heavy service and want typed request/response models |
| flask | PyPI | You want a small, unopinionated core and are happy assembling your own stack piece by piece |
| litestar | PyPI | You want a modern typed ASGI framework with more batteries than Flask but less legacy than Django |