23 KiB
Backend Frameworks — Django Patterns
Django
When to Use
- Python web applications
- Admin interfaces
- Django REST Framework APIs
- Content-heavy sites with ORM-driven data models
When NOT to Use
- FastAPI projects — use the
fastapiskill instead for async APIs and microservices - JavaScript/Node.js backends (Express, NestJS) — this skill is Python-only
- Microservices architectures — consider FastAPI instead for lightweight, async services
Core Patterns
1. Models & ORM
Field types and relationships
from django.db import models
from django.utils import timezone
class Organization(models.Model):
name = models.CharField(max_length=200)
slug = models.SlugField(unique=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["name"]
def __str__(self):
return self.name
class User(models.Model):
class Role(models.TextChoices):
ADMIN = "admin", "Administrator"
MEMBER = "member", "Member"
VIEWER = "viewer", "Viewer"
email = models.EmailField(unique=True)
name = models.CharField(max_length=100)
organization = models.ForeignKey(
Organization,
on_delete=models.CASCADE,
related_name="members",
)
role = models.CharField(max_length=20, choices=Role.choices, default=Role.MEMBER)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created_at"]
indexes = [
models.Index(fields=["email"]),
models.Index(fields=["organization", "role"]),
]
constraints = [
models.UniqueConstraint(
fields=["organization", "email"],
name="unique_org_email",
),
]
def __str__(self):
return self.email
class Tag(models.Model):
name = models.CharField(max_length=50, unique=True)
class Project(models.Model):
title = models.CharField(max_length=200)
description = models.TextField(blank=True)
owner = models.ForeignKey(User, on_delete=models.CASCADE, related_name="owned_projects")
organization = models.ForeignKey(Organization, on_delete=models.CASCADE)
tags = models.ManyToManyField(Tag, blank=True, related_name="projects")
# OneToOneField for 1:1 relationships
settings = models.OneToOneField(
"ProjectSettings", on_delete=models.CASCADE, null=True, blank=True
)
class ProjectSettings(models.Model):
is_public = models.BooleanField(default=False)
max_members = models.IntegerField(default=10)
Custom managers and QuerySet methods
class ActiveManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(is_active=True)
class UserQuerySet(models.QuerySet):
def admins(self):
return self.filter(role=User.Role.ADMIN)
def in_organization(self, org_id):
return self.filter(organization_id=org_id)
def with_project_count(self):
return self.annotate(project_count=models.Count("owned_projects"))
class User(models.Model):
# ... fields ...
objects = UserQuerySet.as_manager()
active = ActiveManager()
F objects, Q objects, and annotations
from django.db.models import F, Q, Count, Avg, Sum, Value, When, Case
# F objects: reference model fields in queries
Project.objects.filter(updated_at__gt=F("created_at"))
User.objects.update(login_count=F("login_count") + 1) # Atomic increment
# Q objects: complex lookups with OR, AND, NOT
User.objects.filter(
Q(role="admin") | Q(role="member"),
~Q(is_active=False), # NOT inactive
)
# Annotations and aggregations
orgs = Organization.objects.annotate(
member_count=Count("members"),
admin_count=Count("members", filter=Q(members__role="admin")),
avg_projects=Avg("members__owned_projects"),
).filter(member_count__gte=5)
# Conditional expressions
users = User.objects.annotate(
tier=Case(
When(owned_projects__count__gte=10, then=Value("power")),
When(owned_projects__count__gte=3, then=Value("active")),
default=Value("starter"),
)
)
# Subqueries
from django.db.models import Subquery, OuterRef
latest_project = Project.objects.filter(
owner=OuterRef("pk")
).order_by("-created_at").values("title")[:1]
users = User.objects.annotate(latest_project_title=Subquery(latest_project))
2. Views
Function-based views
from django.shortcuts import render, get_object_or_404, redirect
from django.http import JsonResponse
from django.contrib.auth.decorators import login_required
@login_required
def project_detail(request, project_id):
project = get_object_or_404(
Project.objects.select_related("owner", "organization"),
pk=project_id,
)
if request.method == "POST":
form = ProjectForm(request.POST, instance=project)
if form.is_valid():
form.save()
return redirect("project-detail", project_id=project.id)
else:
form = ProjectForm(instance=project)
return render(request, "projects/detail.html", {
"project": project,
"form": form,
})
Class-based views
from django.views.generic import ListView, DetailView, CreateView, UpdateView, DeleteView
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.urls import reverse_lazy
class ProjectListView(LoginRequiredMixin, ListView):
model = Project
template_name = "projects/list.html"
context_object_name = "projects"
paginate_by = 20
def get_queryset(self):
qs = super().get_queryset().select_related("owner", "organization")
search = self.request.GET.get("q")
if search:
qs = qs.filter(
Q(title__icontains=search) | Q(description__icontains=search)
)
return qs
class ProjectCreateView(LoginRequiredMixin, CreateView):
model = Project
form_class = ProjectForm
template_name = "projects/form.html"
success_url = reverse_lazy("project-list")
def form_valid(self, form):
form.instance.owner = self.request.user
form.instance.organization = self.request.user.organization
return super().form_valid(form)
class ProjectDeleteView(PermissionRequiredMixin, DeleteView):
model = Project
permission_required = "projects.delete_project"
success_url = reverse_lazy("project-list")
Mixins for reuse
class OrganizationFilterMixin:
"""Filter queryset to the current user's organization."""
def get_queryset(self):
return super().get_queryset().filter(
organization=self.request.user.organization
)
class ProjectListView(LoginRequiredMixin, OrganizationFilterMixin, ListView):
model = Project
# queryset is automatically filtered by organization
API views with Django REST Framework
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework import status
@api_view(["GET", "POST"])
@permission_classes([IsAuthenticated])
def project_list(request):
if request.method == "GET":
projects = Project.objects.filter(organization=request.user.organization)
serializer = ProjectSerializer(projects, many=True)
return Response(serializer.data)
serializer = ProjectCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
serializer.save(owner=request.user)
return Response(serializer.data, status=status.HTTP_201_CREATED)
3. Migrations
Creating and running migrations
# Generate migrations after model changes
python manage.py makemigrations app_name
# Preview SQL without applying
python manage.py sqlmigrate app_name 0001
# Apply migrations
python manage.py migrate
# Show migration status
python manage.py showmigrations
Data migrations with RunPython
from django.db import migrations
def populate_slugs(apps, schema_editor):
Organization = apps.get_model("myapp", "Organization")
from django.utils.text import slugify
for org in Organization.objects.filter(slug=""):
org.slug = slugify(org.name)
org.save(update_fields=["slug"])
def reverse_populate_slugs(apps, schema_editor):
pass # No-op reverse
class Migration(migrations.Migration):
dependencies = [
("myapp", "0005_add_slug_field"),
]
operations = [
migrations.RunPython(populate_slugs, reverse_populate_slugs),
]
Squashing migrations
# Squash migrations 0001 through 0010 into one
python manage.py squashmigrations app_name 0001 0010
Tips:
- Always provide a reverse function for
RunPython(even if it is a no-op) - Use
apps.get_model()in data migrations, never import models directly - Test migrations on a copy of production data before deploying
4. Forms
ModelForm with custom validation
from django import forms
from django.core.exceptions import ValidationError
class ProjectForm(forms.ModelForm):
class Meta:
model = Project
fields = ["title", "description", "tags"]
widgets = {
"description": forms.Textarea(attrs={"rows": 4}),
"tags": forms.CheckboxSelectMultiple(),
}
def clean_title(self):
title = self.cleaned_data["title"]
if "test" in title.lower() and not self.instance.pk:
raise ValidationError("Title cannot contain 'test' for new projects.")
return title
def clean(self):
cleaned = super().clean()
title = cleaned.get("title", "")
description = cleaned.get("description", "")
if len(title) + len(description) < 20:
raise ValidationError("Title + description must be at least 20 characters.")
return cleaned
Formsets
from django.forms import inlineformset_factory
TaskFormSet = inlineformset_factory(
Project,
Task,
fields=["title", "assigned_to", "due_date"],
extra=2, # Number of empty forms
can_delete=True,
max_num=20,
)
# In a view
def project_tasks(request, project_id):
project = get_object_or_404(Project, pk=project_id)
if request.method == "POST":
formset = TaskFormSet(request.POST, instance=project)
if formset.is_valid():
formset.save()
return redirect("project-detail", project_id=project.id)
else:
formset = TaskFormSet(instance=project)
return render(request, "projects/tasks.html", {"formset": formset})
5. Signals
from django.db.models.signals import post_save, pre_save, m2m_changed
from django.dispatch import receiver
@receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs):
if created:
UserProfile.objects.create(user=instance)
@receiver(pre_save, sender=Project)
def set_project_slug(sender, instance, **kwargs):
if not instance.slug:
from django.utils.text import slugify
instance.slug = slugify(instance.title)
# Custom signals
from django.dispatch import Signal
project_published = Signal() # Accepts sender
@receiver(project_published)
def notify_members(sender, project, **kwargs):
for member in project.organization.members.all():
send_notification(member, f"Project '{project.title}' published")
# Firing a custom signal
project_published.send(sender=Project, project=project)
When to use signals vs overriding save():
- Use signals when the action is a side effect (notifications, logging, cache invalidation)
- Override
save()when the logic is core to the model's behavior (setting computed fields)
6. Middleware
import time
from django.utils.deprecation import MiddlewareMixin
class TimingMiddleware(MiddlewareMixin):
def process_request(self, request):
request._start_time = time.perf_counter()
def process_response(self, request, response):
if hasattr(request, "_start_time"):
duration = time.perf_counter() - request._start_time
response["X-Process-Time"] = f"{duration:.4f}"
return response
# New-style middleware (function-based)
def organization_middleware(get_response):
def middleware(request):
if request.user.is_authenticated:
request.organization = request.user.organization
else:
request.organization = None
response = get_response(request)
return response
return middleware
# Register in settings.py
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"myapp.middleware.organization_middleware", # Custom
"myapp.middleware.TimingMiddleware", # Custom
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
7. Django REST Framework
Serializers
from rest_framework import serializers
class UserSerializer(serializers.ModelSerializer):
project_count = serializers.IntegerField(read_only=True)
full_name = serializers.SerializerMethodField()
class Meta:
model = User
fields = ["id", "email", "name", "role", "full_name", "project_count", "created_at"]
read_only_fields = ["id", "created_at"]
def get_full_name(self, obj):
return f"{obj.name} ({obj.role})"
class ProjectSerializer(serializers.ModelSerializer):
owner = UserSerializer(read_only=True)
tags = serializers.SlugRelatedField(
many=True, slug_field="name", queryset=Tag.objects.all()
)
class Meta:
model = Project
fields = ["id", "title", "description", "owner", "tags", "created_at"]
def validate_title(self, value):
if len(value) < 3:
raise serializers.ValidationError("Title must be at least 3 characters.")
return value
class ProjectCreateSerializer(serializers.ModelSerializer):
class Meta:
model = Project
fields = ["title", "description", "tags"]
ViewSets and routers
from rest_framework import viewsets, permissions, filters
from rest_framework.decorators import action
from rest_framework.response import Response
from django_filters.rest_framework import DjangoFilterBackend
class ProjectViewSet(viewsets.ModelViewSet):
serializer_class = ProjectSerializer
permission_classes = [permissions.IsAuthenticated]
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
filterset_fields = ["owner", "tags"]
search_fields = ["title", "description"]
ordering_fields = ["created_at", "title"]
ordering = ["-created_at"]
def get_queryset(self):
return Project.objects.filter(
organization=self.request.user.organization
).select_related("owner").prefetch_related("tags")
def get_serializer_class(self):
if self.action == "create":
return ProjectCreateSerializer
return ProjectSerializer
def perform_create(self, serializer):
serializer.save(
owner=self.request.user,
organization=self.request.user.organization,
)
@action(detail=True, methods=["post"])
def publish(self, request, pk=None):
project = self.get_object()
project.is_published = True
project.save(update_fields=["is_published"])
return Response({"status": "published"})
# urls.py
from rest_framework.routers import DefaultRouter
router = DefaultRouter()
router.register("projects", ProjectViewSet, basename="project")
router.register("users", UserViewSet, basename="user")
urlpatterns = [
path("api/", include(router.urls)),
]
Permissions
from rest_framework.permissions import BasePermission
class IsOrganizationAdmin(BasePermission):
def has_permission(self, request, view):
return (
request.user.is_authenticated
and request.user.role == User.Role.ADMIN
)
class IsOwnerOrReadOnly(BasePermission):
def has_object_permission(self, request, view, obj):
if request.method in permissions.SAFE_METHODS:
return True
return obj.owner == request.user
Pagination
from rest_framework.pagination import PageNumberPagination, CursorPagination
class StandardPagination(PageNumberPagination):
page_size = 20
page_size_query_param = "page_size"
max_page_size = 100
class TimelinePagination(CursorPagination):
page_size = 50
ordering = "-created_at"
# settings.py
REST_FRAMEWORK = {
"DEFAULT_PAGINATION_CLASS": "myapp.pagination.StandardPagination",
"PAGE_SIZE": 20,
"DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"],
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework_simplejwt.authentication.JWTAuthentication",
],
}
8. Admin
from django.contrib import admin
from django.utils.html import format_html
class TaskInline(admin.TabularInline):
model = Task
extra = 0
fields = ["title", "assigned_to", "status", "due_date"]
readonly_fields = ["created_at"]
@admin.register(Project)
class ProjectAdmin(admin.ModelAdmin):
list_display = ["title", "owner_name", "organization", "tag_list", "created_at"]
list_filter = ["organization", "tags", "created_at"]
search_fields = ["title", "description", "owner__email"]
readonly_fields = ["created_at", "updated_at"]
autocomplete_fields = ["owner", "organization"]
prepopulated_fields = {"slug": ("title",)}
date_hierarchy = "created_at"
inlines = [TaskInline]
fieldsets = (
(None, {
"fields": ("title", "slug", "description"),
}),
("Ownership", {
"fields": ("owner", "organization", "tags"),
}),
("Metadata", {
"classes": ("collapse",),
"fields": ("created_at", "updated_at"),
}),
)
def owner_name(self, obj):
return obj.owner.name
owner_name.short_description = "Owner"
owner_name.admin_order_field = "owner__name"
def tag_list(self, obj):
return ", ".join(t.name for t in obj.tags.all())
tag_list.short_description = "Tags"
def get_queryset(self, request):
return super().get_queryset(request).select_related(
"owner", "organization"
).prefetch_related("tags")
# Custom admin actions
@admin.action(description="Mark selected projects as published")
def make_published(self, request, queryset):
count = queryset.update(is_published=True)
self.message_user(request, f"{count} projects published.")
actions = [make_published]
@admin.register(User)
class UserAdmin(admin.ModelAdmin):
list_display = ["email", "name", "organization", "role", "is_active"]
list_filter = ["role", "is_active", "organization"]
search_fields = ["email", "name"]
list_editable = ["role", "is_active"]
list_per_page = 50
Best Practices
-
Use
select_relatedandprefetch_relatedon every query that touches relations —select_relatedfor ForeignKey/OneToOne (SQL JOIN),prefetch_relatedfor ManyToMany and reverse ForeignKey (separate query). Check queries withdjango-debug-toolbar. -
Keep business logic in model methods or service functions, not in views — views should handle HTTP, forms should handle validation, models/services should handle domain logic. This makes code testable without needing HTTP.
-
Use
get_queryset()for dynamic filtering instead of hardcoding querysets — both in views and DRF ViewSets. This enables mixin composition and per-request filtering (e.g., by organization). -
Write data migrations for schema changes that require backfills — never assume fields can be added as non-nullable without a migration to populate existing rows. Use
RunPythonwith a reverse function. -
Configure Django REST Framework defaults in settings — set
DEFAULT_PAGINATION_CLASS,DEFAULT_PERMISSION_CLASSES,DEFAULT_AUTHENTICATION_CLASSESinREST_FRAMEWORKdict to avoid repeating yourself on each ViewSet. -
Use
TextChoices/IntegerChoicesfor enum fields — they integrate with admin filters, serializer validation, and migrations automatically. Avoid plain strings or integers for status/role fields. -
Index frequently queried fields — add
db_index=Trueon individual fields or useMeta.indexesfor composite indexes. AddUniqueConstraintfor business-rule uniqueness. -
Use Django's
transaction.atomic()for multi-step writes — wrap create/update sequences that must succeed or fail together. DRF'sperform_createandperform_updateare good places for this.
from django.db import transaction
@transaction.atomic
def transfer_project(project, new_owner):
old_owner = project.owner
project.owner = new_owner
project.save(update_fields=["owner"])
AuditLog.objects.create(
action="transfer",
project=project,
from_user=old_owner,
to_user=new_owner,
)
Common Pitfalls
-
N+1 queries — accessing
project.owner.namein a loop withoutselect_related("owner")fires one query per iteration. Usedjango-debug-toolbarornplusoneto detect these. Always optimize queryset inget_queryset(). -
Importing models directly in data migrations — models change over time, but migrations are frozen. Always use
apps.get_model("app_name", "ModelName")insideRunPythonfunctions, neverfrom myapp.models import Model. -
Forgetting to call
full_clean()in model saves — Django'ssave()does NOT run validators by default. Only forms and serializers callfull_clean(). If you save models directly, add explicit validation. -
Circular imports between apps — referencing models across apps can cause import cycles. Use string references in ForeignKey:
models.ForeignKey("other_app.ModelName", ...)instead of importing the class. -
Overusing signals for core logic — signals make code harder to trace and debug. Use them for side effects (sending emails, cache invalidation), not for core domain logic. If logic should always run on save, override
save()instead. -
Returning entire QuerySets from service functions — QuerySets are lazy, which is usually good, but returning them from service layers can lead to unexpected queries executing in templates. Use
.values(),.values_list(), or serialize to dicts when crossing layer boundaries.
Related Skills
python— Python language patterns and best practicespostgresql— Database integration and query optimizationpytest— Testing Django applications with pytest-django