What is Django?
Django is a batteries-included web framework for Python. Where most frameworks ask you to choose a database layer, a template engine, an admin panel and an authentication system, Django hands you all of them in one coherent package. You describe your data and your pages, and the framework handles the tedious, security-sensitive parts.
It was built for a newspaper newsroom in 2003 — a team that needed to ship features fast without breaking under deadline. That origin shows. Django favours convention, explicitness and a “there should be one obvious way” attitude, and it has stayed one of the most dependable choices for content-heavy and data-driven sites.
If you have ever spent a week wiring an ORM, a migration tool and an auth system together, Django is the framework that removes that week.
The MTV pattern
Django calls its architecture MTV: Model, Template, View. It is the same idea as MVC with different names, and the difference trips people up at first.
- A model is a Python class that describes a table and its rows.
- A template is an HTML file with placeholders that renders the data.
- A view is a function or class that receives a request, asks the models for data, and returns a response.
The controller in the classic MVC sense is Django itself. The URL dispatcher decides which view runs, and the framework mediates between the three layers. Once that diagram is clear, everything else in Django becomes a detail rather than a mystery.
Models and the ORM
A model is a class that inherits from models.Model. Each attribute is a field, and each field becomes a column.
# blog/models.py
from django.conf import settings
from django.db import models
class Post(models.Model):
title = models.CharField(max_length=200)
slug = models.SlugField(max_length=200, unique=True)
body = models.TextField()
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="posts",
)
published_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return self.title
CharField needs a max_length because it maps to a bounded VARCHAR. TextField is unbounded. auto_now_add=True sets a value only on creation, while auto_now=True updates on every save. null controls the database, and blank controls form validation; they are separate on purpose.
Relationships are explicit:
ForeignKeyis many-to-one.ManyToManyFieldcreates a join table.OneToOneFieldis a unique foreign key.
The related_name sets the reverse accessor, so user.posts.all() works from the other side. The on_delete argument is required because Django refuses to guess what should happen to children when a parent is deleted.
Migrations
Migrations are version-controlled files that describe changes to your schema. You write the model, Django writes the SQL.
python manage.py makemigrations blog
python manage.py migrate
makemigrations inspects your models and produces a migration; migrate applies pending migrations to the database. Because the migration files live in your repository, every environment ends up with the same schema, and changes are reviewable like any other code. Never edit an applied migration — add a new one instead.
Querysets and relationships
The ORM returns querysets, which are lazy: they are not executed until you iterate, slice or evaluate them. That laziness lets you chain filters and only pay for the final query.
Post.objects.filter(status="PB").exclude(author=user).order_by("-published_at")[:10]
Useful queryset methods include filter, exclude, get, first, exists, count, values, values_list, annotate and aggregate. The F() expressions let you reference a column in the database, and Q() objects let you build OR conditions.
from django.db.models import Count, Q
published = Post.objects.filter(
Q(status="PB") | Q(published_at__isnull=False)
).annotate(comment_count=Count("comments"))
This is where the ORM earns its keep: complex queries stay in Python, are parameterised safely, and compose.
Avoiding the N+1 problem
The single most common performance mistake in Django is the N+1 query. It happens when you access a related object inside a loop, triggering one extra query per row.
# 1 query for posts, then 1 query per post for the author
for post in Post.objects.all():
print(post.author.username)
select_related joins single-valued relations into the original query, and prefetch_related runs a second query and stitches the results together in Python for multi-valued relations.
# 1 query for posts joined with author, 1 for the comments
posts = Post.objects.select_related("author").prefetch_related("comments")
Reach for select_related on foreign keys and one-to-one fields, and prefetch_related on many-to-many and reverse relations. If a page feels slow, count the queries with the Django Debug Toolbar before optimising anything else.
Views: functions and classes
A view is any callable that takes a request and returns a response. Function-based views are the clearest place to start.
# blog/views.py
from django.http import JsonResponse
from django.shortcuts import get_object_or_404, render
from .models import Post
def post_list(request):
posts = Post.objects.filter(status="PB").select_related("author")
return render(request, "blog/post_list.html", {"posts": posts})
def post_api(request, slug):
post = get_object_or_404(Post, slug=slug)
return JsonResponse({"title": post.title, "body": post.body})
Class-based views trade a little indirection for reuse. ListView, DetailView, CreateView, UpdateView and DeleteView implement the standard CRUD patterns, and mixins let you compose behaviour.
from django.views.generic import ListView
from .models import Post
class PostListView(ListView):
model = Post
template_name = "blog/post_list.html"
context_object_name = "posts"
paginate_by = 10
def get_queryset(self):
return Post.objects.filter(status="PB").select_related("author")
Use function-based views for one-off logic and generic class-based views for standard list and detail pages. Mixing both in a codebase is normal and healthy.
URLs
The URL dispatcher maps a path to a view. Patterns live in urls.py, and each app can keep its own.
# config/urls.py
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path("admin/", admin.site.urls),
path("blog/", include("blog.urls")),
]
# blog/urls.py
from django.urls import path
from . import views
app_name = "blog"
urlpatterns = [
path("", views.post_list, name="post_list"),
path("<slug:slug>/", views.post_detail, name="post_detail"),
]
Converters such as <int:id> and <slug:slug> validate and type the captured segment. Always name your routes and reverse them with reverse() or the {% url %} tag instead of hardcoding paths, so links survive a rename.
Templates
Templates are HTML with a small, intentionally limited language. Autoescaping is on by default, which is what protects you from XSS.
{% extends "base.html" %}
{% block content %}
<h1>{{ post.title }}</h1>
<p>By {{ post.author.username }} on {{ post.published_at|date:"F j, Y" }}</p>
<div>{{ post.body|linebreaks }}</div>
{% endblock %}
{% extends %} gives template inheritance, {% include %} shares fragments, and filters such as date, linebreaks and default format values. There is no arbitrary Python in templates; if a template needs logic, that logic belongs in the view or the model.
Forms and validation
Django’s form system validates input, renders the HTML and protects against tampering with a CSRF token.
# blog/forms.py
from django import forms
from .models import Post
class PostForm(forms.ModelForm):
class Meta:
model = Post
fields = ["title", "slug", "body", "status", "published_at"]
widgets = {"body": forms.Textarea(attrs={"rows": 12})}
# blog/views.py
from django.shortcuts import redirect, render
from .forms import PostForm
def post_create(request):
if request.method == "POST":
form = PostForm(request.POST)
if form.is_valid():
post = form.save(commit=False)
post.author = request.user
post.save()
return redirect(post)
else:
form = PostForm()
return render(request, "blog/post_form.html", {"form": form})
A ModelForm derives fields and validation from the model, so the two never drift. Clean methods such as clean_slug add custom rules, and form.is_valid() collects every error before you touch the database.
The admin
The admin is Django’s most underrated feature. Register a model and you get a searchable, filterable, permission-aware interface for staff.
# blog/admin.py
from django.contrib import admin
from .models import Post
@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
list_display = ["title", "author", "status", "published_at"]
list_filter = ["status", "created_at"]
search_fields = ["title", "body"]
prepopulated_fields = {"slug": ["title"]}
raw_id_fields = ["author"]
date_hierarchy = "published_at"
That is a complete internal tool in a dozen lines. For content sites, the admin is often the product. It is also a security boundary: staff permissions, has_add_permission and has_change_permission control exactly what each role can do.
Settings and apps
A Django project is a collection of apps, each a self-contained package with its own models, views, templates and migrations. INSTALLED_APPS lists them, and settings.py holds configuration.
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"blog",
]
Keep settings out of source control secrets. Read them from environment variables, split settings per environment if the project grows, and use django-environ or pydantic-settings to parse them. The django-admin startproject and startapp commands scaffold both layers so the structure is never in question.
Security defaults
Django’s defaults are chosen to make the safe thing the easy thing:
- CSRF tokens protect state-changing forms; the middleware rejects requests without them.
- SQL injection is avoided because querysets parameterise values.
- XSS is mitigated because templates escape variables unless you opt out with
|safe. - Passwords are hashed with a modern algorithm and configurable hashers.
- Clickjacking is blocked by
X-Frame-Optionsmiddleware. - HTTPS is enforced in production with
SECURE_SSL_REDIRECT,SESSION_COOKIE_SECUREandCSRF_COOKIE_SECURE.
Run python manage.py check --deploy before shipping. It flags the settings you forgot, and it is far cheaper than a security review.
Building APIs with Django REST Framework
For JSON APIs, Django REST Framework (DRF) is the standard companion. A serializer describes how a model becomes JSON and back, and a viewset plus a router generates the CRUD endpoints.
# blog/serializers.py
from rest_framework import serializers
from .models import Post
class PostSerializer(serializers.ModelSerializer):
author = serializers.StringRelatedField()
class Meta:
model = Post
fields = ["id", "title", "slug", "author", "status", "published_at"]
# blog/api.py
from rest_framework import viewsets
from .models import Post
from .serializers import PostSerializer
class PostViewSet(viewsets.ModelViewSet):
queryset = Post.objects.select_related("author")
serializer_class = PostSerializer
lookup_field = "slug"
# blog/urls.py
from rest_framework.routers import DefaultRouter
from .api import PostViewSet
router = DefaultRouter()
router.register("posts", PostViewSet, basename="post")
urlpatterns = router.urls
Serializers validate input the way forms do, viewsets provide list, create, retrieve, update and delete for free, and DRF can generate OpenAPI schemas for your API. Django’s ORM and DRF’s conventions are a well-worn path for production APIs.
Testing
Django’s test runner creates a fresh test database and provides a client that speaks the full request pipeline.
# blog/tests.py
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from .models import Post
class PostDetailTests(TestCase):
def setUp(self):
user = get_user_model().objects.create_user("ada", password="secret")
self.post = Post.objects.create(
title="Hello",
slug="hello",
body="First post",
author=user,
status=Post.Status.PUBLISHED,
)
def test_detail_page_renders(self):
response = self.client.get(reverse("blog:post_detail", args=["hello"]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Hello")
def test_missing_post_returns_404(self):
response = self.client.get(reverse("blog:post_detail", args=["nope"]))
self.assertEqual(response.status_code, 404)
TestCase wraps each test in a transaction and rolls it back, so tests stay isolated and fast. Use django.test.Client for views, the ORM directly for models, and pytest-django if you prefer pytest fixtures. Aim to test behaviour and permissions, not implementation details.
Best practices
- Keep models focused on data and small domain methods; put orchestration in services or views.
- Always run
select_relatedandprefetch_relatedwhere a template touches relations. - Use
ModelFormand DRF serializers so validation lives in one place. - Name every URL and reverse it; never hardcode paths in templates or code.
- Add database indexes for columns you filter and order by.
- Keep secrets in environment variables and run
check --deploybefore release. - Write migrations as code review artifacts and never edit an applied one.
- Test the request pipeline with
Clientand the domain logic directly.
Common mistakes
- Iterating over a queryset and touching relations inside the loop, causing N+1 queries.
- Using
null=Trueon a string field instead of an empty string, then fightingNonechecks. - Putting business logic in templates because the view felt busy.
- Calling
save()in a loop instead ofbulk_createorbulk_update. - Forgetting
on_deleteor choosingCASCADEwithout thinking about data loss. - Leaving
DEBUG = Trueand a hardcodedSECRET_KEYin production. - Treating the admin as a user-facing frontend instead of a staff tool.
- Querying with
len(queryset)instead of.count(), loading every row into memory.
Where to go next
Django gives you the complete, conventional path to a production Python web app. If you want a lighter core and more choices, read the Flask guide next. If you are building a modern async JSON API and want types to do the validation, move to FastAPI. Whichever you pick, invest in the database underneath: indexing, transactions and query planning matter more than the framework, and the backend roadmap goes deeper. The REST APIs guide covers the design rules that make an API pleasant to consume.