What is Django? The Python Framework That Runs Instagram
Django is the Python web framework that ships with an ORM, admin panel, authentication, and migrations out of the box. Instagram built its 2-billion-user backend on it. Here is how it works and when you should use it.
Most web frameworks make you choose between speed of development and production capability. You either get a framework that moves fast but breaks down at scale, or you get an enterprise beast that requires three days of configuration before you can serve a single HTTP response.
Django does not force that choice. It ships with an ORM, a full admin interface, authentication, migrations, form validation, CSRF protection, session management, and a templating engine. You set up a project and you have a production-capable application structure before you write a single line of business logic.
Instagram used Django to build a backend that eventually served 2 billion users. Pinterest, Mozilla, Disqus, Bitbucket, and the Washington Post all run Django in production. It has been doing this since 2005. The framework that makes developers productive on day one is the same framework powering some of the most trafficked sites on the internet.
This article covers what Django is, how its architecture works, and what production Django code looks like with real examples.
The Origin
Adrian Holovaty and Simon Willison built Django in 2003 while working at the Lawrence Journal-World newspaper in Lawrence, Kansas. The newspaper needed to publish content to the web quickly. A journalist would write a story and it needed to be live within minutes. The team needed a framework that let a small engineering team move fast.
They open-sourced Django in July 2005. The name comes from Django Reinhardt, the jazz guitarist. Holovaty, who is also a musician, named the framework after him.
The newspaper origin explains a lot about Django's design. Newsrooms move at deadline speed. The framework needed to let developers build data-driven content sites without reinventing authentication, database modeling, and admin interfaces on every project. The "batteries included" philosophy came directly from that pressure.
The Architecture: MTV
Django calls its architecture MTV: Model, Template, View. Other frameworks call the equivalent pattern MVC (Model, View, Controller). The names map loosely: Django's View is roughly equivalent to a controller, and its Template handles the view layer. The naming is different but the separation of concerns is identical.
Model defines your data structure. A Django model is a Python class that maps to a database table. The ORM handles generating SQL from your model definitions.
Template handles presentation. Django's template language is intentionally limited. You cannot run arbitrary Python in templates. This forces business logic into views and models, where it belongs.
View handles request processing. A Django view receives an HTTP request, fetches or manipulates data through models, and returns an HTTP response. The response is often a rendered template, but for APIs it is JSON.
The URL dispatcher maps URL patterns to views. Django reads your URL configuration, matches the incoming URL to a pattern, and calls the associated view function or class.
Setting Up a Django Project
pip install django djangorestframework django-environ psycopg2-binary
django-admin startproject myapp .
python manage.py startapp api
python manage.py startapp users
Django projects contain apps. An app is a self-contained component with its own models, views, and URL routing. The convention is to split functionality by domain: one app for user management, one for your core product, one for billing. This keeps large projects navigable.
The project structure looks like this after setup:
myapp/
__init__.py
settings.py
urls.py
wsgi.py
asgi.py
api/
__init__.py
models.py
views.py
serializers.py
urls.py
admin.py
apps.py
users/
__init__.py
models.py
views.py
serializers.py
urls.py
admin.py
manage.py
Settings in production need environment variables for secrets. The django-environ package handles this cleanly:
import environ
from pathlib import Path
env = environ.Env(DEBUG=(bool, False))
BASE_DIR = Path(__file__).resolve().parent.parent
environ.Env.read_env(BASE_DIR / '.env')
SECRET_KEY = env('SECRET_KEY')
DEBUG = env('DEBUG')
ALLOWED_HOSTS = env.list('ALLOWED_HOSTS')
DATABASES = {
'default': env.db()
}
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework',
'rest_framework.authtoken',
'api',
'users',
]
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.TokenAuthentication',
'rest_framework.authentication.SessionAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
],
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 20,
'DEFAULT_THROTTLE_CLASSES': [
'rest_framework.throttling.AnonRateThrottle',
'rest_framework.throttling.UserRateThrottle',
],
'DEFAULT_THROTTLE_RATES': {
'anon': '100/hour',
'user': '1000/hour',
}
}
STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
The .env file keeps secrets out of version control:
SECRET_KEY=your-secret-key-here
DEBUG=False
ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com
DATABASE_URL=postgres://user:password@localhost/myapp
env.db() parses a database URL string into Django's DATABASES configuration format. One line replaces what used to be five lines of host/port/user/password configuration.
Django Models: The ORM
The ORM is where Django earns its "batteries included" reputation. You define Python classes, run migrations, and the ORM handles generating and executing SQL.
from django.db import models
from django.contrib.auth.models import AbstractUser
from django.core.validators import MinValueValidator, MaxValueValidator
class User(AbstractUser):
bio = models.TextField(blank=True)
avatar = models.ImageField(upload_to='avatars/', null=True, blank=True)
is_verified = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
indexes = [
models.Index(fields=['email']),
models.Index(fields=['username']),
]
def __str__(self):
return self.username
@property
def full_name(self):
return f"{self.first_name} {self.last_name}".strip() or self.username
class Category(models.Model):
name = models.CharField(max_length=100, unique=True)
slug = models.SlugField(max_length=100, unique=True)
description = models.TextField(blank=True)
parent = models.ForeignKey(
'self',
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='children'
)
class Meta:
verbose_name_plural = 'categories'
ordering = ['name']
def __str__(self):
return self.name
class Product(models.Model):
class Status(models.TextChoices):
DRAFT = 'draft', 'Draft'
ACTIVE = 'active', 'Active'
ARCHIVED = 'archived', 'Archived'
name = models.CharField(max_length=255)
slug = models.SlugField(max_length=255, unique=True)
description = models.TextField()
price = models.DecimalField(max_digits=10, decimal_places=2, validators=[MinValueValidator(0)])
stock = models.PositiveIntegerField(default=0)
category = models.ForeignKey(Category, on_delete=models.PROTECT, related_name='products')
status = models.CharField(max_length=10, choices=Status.choices, default=Status.DRAFT)
created_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name='products')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
indexes = [
models.Index(fields=['status', 'created_at']),
models.Index(fields=['category', 'status']),
models.Index(fields=['slug']),
]
ordering = ['-created_at']
def __str__(self):
return self.name
@property
def in_stock(self):
return self.stock > 0
def reserve_stock(self, quantity: int) -> bool:
if self.stock < quantity:
return False
self.stock -= quantity
self.save(update_fields=['stock', 'updated_at'])
return True
class Order(models.Model):
class Status(models.TextChoices):
PENDING = 'pending', 'Pending'
CONFIRMED = 'confirmed', 'Confirmed'
SHIPPED = 'shipped', 'Shipped'
DELIVERED = 'delivered', 'Delivered'
CANCELLED = 'cancelled', 'Cancelled'
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='orders')
status = models.CharField(max_length=10, choices=Status.choices, default=Status.PENDING)
total_amount = models.DecimalField(max_digits=10, decimal_places=2)
shipping_address = models.JSONField()
notes = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
indexes = [
models.Index(fields=['user', 'status']),
models.Index(fields=['status', 'created_at']),
]
ordering = ['-created_at']
def __str__(self):
return f"Order #{self.pk} - {self.user.username}"
class OrderItem(models.Model):
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name='items')
product = models.ForeignKey(Product, on_delete=models.PROTECT, related_name='order_items')
quantity = models.PositiveIntegerField(validators=[MinValueValidator(1)])
unit_price = models.DecimalField(max_digits=10, decimal_places=2)
class Meta:
unique_together = ['order', 'product']
@property
def subtotal(self):
return self.quantity * self.unit_price
def __str__(self):
return f"{self.quantity}x {self.product.name}"
class Review(models.Model):
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='reviews')
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='reviews')
rating = models.PositiveSmallIntegerField(
validators=[MinValueValidator(1), MaxValueValidator(5)]
)
title = models.CharField(max_length=255)
body = models.TextField()
is_verified_purchase = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ['product', 'user']
indexes = [
models.Index(fields=['product', 'rating']),
]
def __str__(self):
return f"{self.user.username} - {self.product.name} ({self.rating}/5)"
Django's field types map directly to database column types. CharField maps to VARCHAR. TextField maps to TEXT. DecimalField maps to NUMERIC. JSONField maps to JSONB in PostgreSQL.
on_delete=models.PROTECT prevents deleting a category that still has products attached to it. The database raises an error. on_delete=models.CASCADE deletes child records when the parent is deleted. on_delete=models.SET_NULL sets the foreign key to null. Choose based on your data integrity requirements.
auto_now_add=True sets the timestamp when the record is first created. auto_now=True updates the timestamp on every save. These are the two timestamps every table should have.
unique_together = ['order', 'product'] creates a compound unique constraint at the database level. One product can only appear once per order.
update_fields=['stock', 'updated_at'] in reserve_stock tells Django to only update those specific columns instead of updating every field on the model. On high-traffic tables this matters: a full save() can overwrite changes made by concurrent requests.
Migrations
python manage.py makemigrations
python manage.py migrate
Django reads your model definitions, compares them to the current database schema, and generates migration files describing the changes. Then migrate applies those files to the database.
The migration files are Python code. You commit them to version control alongside your models. When your colleagues pull your changes and run migrate, they get the same schema changes applied to their database.
In production, you run migrations before deploying new code. The deployment sequence is:
python manage.py migrate --noinput
gunicorn myapp.wsgi:application --workers 4 --bind 0.0.0.0:8000
For zero-downtime deployments with significant schema changes, you split the migration into multiple deployments. First deploy adds the new column as nullable. Second deployment backfills data and adds the not-null constraint. Third deployment removes the old column. This is a discipline, not a Django feature, but understanding it keeps your production deployments from causing downtime.
The Django Admin
This is the feature that routinely surprises people who have not used Django before. You get a full admin interface for free.
from django.contrib import admin
from django.utils.html import format_html
from django.db.models import Avg, Count
from .models import User, Product, Order, OrderItem, Category, Review
@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
list_display = ['name', 'slug', 'parent', 'product_count']
list_filter = ['parent']
search_fields = ['name']
prepopulated_fields = {'slug': ('name',)}
def get_queryset(self, request):
return super().get_queryset(request).annotate(
product_count=Count('products')
)
def product_count(self, obj):
return obj.product_count
product_count.admin_order_field = 'product_count'
class OrderItemInline(admin.TabularInline):
model = OrderItem
extra = 0
readonly_fields = ['subtotal']
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
list_display = ['id', 'user', 'status', 'total_amount', 'created_at', 'status_badge']
list_filter = ['status', 'created_at']
search_fields = ['user__username', 'user__email']
readonly_fields = ['created_at', 'updated_at']
inlines = [OrderItemInline]
date_hierarchy = 'created_at'
actions = ['mark_confirmed', 'mark_shipped']
def status_badge(self, obj):
colors = {
'pending': '#ffa500',
'confirmed': '#007bff',
'shipped': '#6f42c1',
'delivered': '#28a745',
'cancelled': '#dc3545',
}
color = colors.get(obj.status, '#6c757d')
return format_html(
'<span style="background-color: {}; color: white; padding: 3px 8px; '
'border-radius: 3px; font-size: 11px;">{}</span>',
color,
obj.get_status_display()
)
status_badge.short_description = 'Status'
@admin.action(description='Mark selected orders as confirmed')
def mark_confirmed(self, request, queryset):
updated = queryset.filter(status='pending').update(status='confirmed')
self.message_user(request, f'{updated} orders marked as confirmed.')
@admin.action(description='Mark selected orders as shipped')
def mark_shipped(self, request, queryset):
updated = queryset.filter(status='confirmed').update(status='shipped')
self.message_user(request, f'{updated} orders marked as shipped.')
@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
list_display = ['name', 'category', 'price', 'stock', 'status', 'avg_rating', 'created_at']
list_filter = ['status', 'category', 'created_at']
search_fields = ['name', 'description']
prepopulated_fields = {'slug': ('name',)}
readonly_fields = ['created_at', 'updated_at']
list_editable = ['stock', 'status']
date_hierarchy = 'created_at'
def get_queryset(self, request):
return super().get_queryset(request).annotate(
avg_rating=Avg('reviews__rating')
)
def avg_rating(self, obj):
if obj.avg_rating:
return f"{obj.avg_rating:.1f} / 5"
return "No reviews"
avg_rating.admin_order_field = 'avg_rating'
You register a model with @admin.register(Model) and define what columns appear in the list view, what fields are searchable, what filter options appear in the sidebar. Django generates the HTML, handles form validation, and manages CRUD operations. You add business logic through list_editable, custom actions, and inline forms for related models.
The inline classes let you edit related records from the parent record's page. OrderItemInline means you can add, edit, and delete order items directly from the order's admin page without navigating away.
list_editable puts form fields directly in the list view so you can bulk-edit records without opening each one individually. Setting stock counts and changing product statuses across a catalog takes seconds.
For non-technical team members, the admin panel is often the only interface they ever need. Content editors, support staff, and operations teams can manage data without touching code or running SQL queries.
Django REST Framework
For API-driven applications, Django REST Framework (DRF) is the standard. It provides serializers for data validation and transformation, view classes for handling HTTP methods, permission classes for authorization, pagination, filtering, and throttling.
from rest_framework import serializers
from django.contrib.auth import get_user_model
from .models import Product, Order, OrderItem, Category, Review
User = get_user_model()
class CategorySerializer(serializers.ModelSerializer):
class Meta:
model = Category
fields = ['id', 'name', 'slug', 'description']
class ProductListSerializer(serializers.ModelSerializer):
category = CategorySerializer(read_only=True)
avg_rating = serializers.FloatField(read_only=True)
review_count = serializers.IntegerField(read_only=True)
class Meta:
model = Product
fields = [
'id', 'name', 'slug', 'price', 'stock',
'category', 'status', 'in_stock',
'avg_rating', 'review_count', 'created_at'
]
class ProductDetailSerializer(ProductListSerializer):
class Meta(ProductListSerializer.Meta):
fields = ProductListSerializer.Meta.fields + ['description']
class ProductCreateSerializer(serializers.ModelSerializer):
class Meta:
model = Product
fields = ['name', 'description', 'price', 'stock', 'category', 'status']
def validate_price(self, value):
if value <= 0:
raise serializers.ValidationError("Price must be greater than zero.")
return value
def validate_stock(self, value):
if value < 0:
raise serializers.ValidationError("Stock cannot be negative.")
return value
def create(self, validated_data):
from django.utils.text import slugify
import uuid
name = validated_data['name']
base_slug = slugify(name)
slug = base_slug
if Product.objects.filter(slug=slug).exists():
slug = f"{base_slug}-{uuid.uuid4().hex[:8]}"
return Product.objects.create(
slug=slug,
created_by=self.context['request'].user,
**validated_data
)
class ReviewSerializer(serializers.ModelSerializer):
user = serializers.StringRelatedField(read_only=True)
class Meta:
model = Review
fields = ['id', 'user', 'rating', 'title', 'body', 'is_verified_purchase', 'created_at']
read_only_fields = ['user', 'is_verified_purchase', 'created_at']
def validate(self, data):
request = self.context['request']
product_id = self.context['view'].kwargs.get('product_pk')
if Review.objects.filter(user=request.user, product_id=product_id).exists():
raise serializers.ValidationError("You have already reviewed this product.")
return data
def create(self, validated_data):
request = self.context['request']
product_id = self.context['view'].kwargs.get('product_pk')
is_verified = Order.objects.filter(
user=request.user,
status='delivered',
items__product_id=product_id
).exists()
return Review.objects.create(
user=request.user,
product_id=product_id,
is_verified_purchase=is_verified,
**validated_data
)
class OrderItemSerializer(serializers.ModelSerializer):
product_name = serializers.CharField(source='product.name', read_only=True)
subtotal = serializers.DecimalField(max_digits=10, decimal_places=2, read_only=True)
class Meta:
model = OrderItem
fields = ['id', 'product', 'product_name', 'quantity', 'unit_price', 'subtotal']
class OrderCreateSerializer(serializers.ModelSerializer):
items = serializers.ListField(
child=serializers.DictField(),
write_only=True
)
class Meta:
model = Order
fields = ['shipping_address', 'notes', 'items']
def validate_items(self, items):
if not items:
raise serializers.ValidationError("Order must contain at least one item.")
validated_items = []
for item in items:
try:
product = Product.objects.get(id=item['product_id'], status='active')
except Product.DoesNotExist:
raise serializers.ValidationError(
f"Product {item.get('product_id')} not found or unavailable."
)
quantity = int(item.get('quantity', 1))
if quantity <= 0:
raise serializers.ValidationError("Quantity must be at least 1.")
if product.stock < quantity:
raise serializers.ValidationError(
f"Insufficient stock for {product.name}. Available: {product.stock}"
)
validated_items.append({
'product': product,
'quantity': quantity,
'unit_price': product.price
})
return validated_items
def create(self, validated_data):
from django.db import transaction
from decimal import Decimal
items_data = validated_data.pop('items')
total = sum(
item['quantity'] * item['unit_price']
for item in items_data
)
with transaction.atomic():
order = Order.objects.create(
user=self.context['request'].user,
total_amount=total,
**validated_data
)
for item_data in items_data:
product = item_data['product']
if not product.reserve_stock(item_data['quantity']):
raise serializers.ValidationError(
f"Stock for {product.name} was depleted during checkout."
)
OrderItem.objects.create(
order=order,
product=product,
quantity=item_data['quantity'],
unit_price=item_data['unit_price']
)
return order
class OrderSerializer(serializers.ModelSerializer):
items = OrderItemSerializer(many=True, read_only=True)
user = serializers.StringRelatedField(read_only=True)
class Meta:
model = Order
fields = ['id', 'user', 'status', 'total_amount', 'shipping_address',
'notes', 'items', 'created_at', 'updated_at']
The serializer layer is where DRF earns its place. ProductCreateSerializer.validate_price() is a field-level validator that runs before the data hits your model. validate() is the object-level validator that runs after all field validators pass and receives the full validated data dict. This lets you write cross-field validation logic.
transaction.atomic() in OrderCreateSerializer.create() wraps the entire order creation in a database transaction. If any step fails, the entire transaction rolls back. The order record does not exist without its items, and stock does not get decremented unless the order item records are created. This is the correct way to handle multi-table writes in any database.
Now the views:
from rest_framework import viewsets, permissions, status, filters
from rest_framework.decorators import action
from rest_framework.response import Response
from django_filters.rest_framework import DjangoFilterBackend
from django.db.models import Avg, Count, Prefetch
from .models import Product, Order, Category, Review
from .serializers import (
ProductListSerializer, ProductDetailSerializer,
ProductCreateSerializer, OrderSerializer, OrderCreateSerializer,
CategorySerializer, ReviewSerializer
)
from .permissions import IsOwnerOrReadOnly, IsAdminOrReadOnly
class CategoryViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Category.objects.all()
serializer_class = CategorySerializer
permission_classes = [permissions.AllowAny]
lookup_field = 'slug'
class ProductViewSet(viewsets.ModelViewSet):
permission_classes = [permissions.IsAuthenticatedOrReadOnly, IsAdminOrReadOnly]
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
filterset_fields = ['category__slug', 'status']
search_fields = ['name', 'description']
ordering_fields = ['price', 'created_at', 'avg_rating']
ordering = ['-created_at']
lookup_field = 'slug'
def get_queryset(self):
return Product.objects.select_related('category', 'created_by').annotate(
avg_rating=Avg('reviews__rating'),
review_count=Count('reviews')
).filter(status='active')
def get_serializer_class(self):
if self.action == 'list':
return ProductListSerializer
if self.action in ['create', 'update', 'partial_update']:
return ProductCreateSerializer
return ProductDetailSerializer
@action(detail=True, methods=['get'], url_path='reviews')
def reviews(self, request, slug=None):
product = self.get_object()
reviews = Review.objects.filter(product=product).select_related('user').order_by('-created_at')
serializer = ReviewSerializer(reviews, many=True, context={'request': request})
return Response(serializer.data)
@action(detail=True, methods=['post'], url_path='reviews',
permission_classes=[permissions.IsAuthenticated])
def create_review(self, request, slug=None):
product = self.get_object()
serializer = ReviewSerializer(
data=request.data,
context={'request': request, 'view': self}
)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
class OrderViewSet(viewsets.ModelViewSet):
permission_classes = [permissions.IsAuthenticated, IsOwnerOrReadOnly]
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
filterset_fields = ['status']
ordering = ['-created_at']
def get_queryset(self):
return Order.objects.filter(user=self.request.user).prefetch_related(
Prefetch('items', queryset=OrderItem.objects.select_related('product'))
)
def get_serializer_class(self):
if self.action == 'create':
return OrderCreateSerializer
return OrderSerializer
def get_serializer_context(self):
context = super().get_serializer_context()
context['view'] = self
return context
@action(detail=True, methods=['post'], url_path='cancel')
def cancel(self, request, pk=None):
order = self.get_object()
if order.status not in ('pending', 'confirmed'):
return Response(
{'detail': 'Only pending or confirmed orders can be cancelled.'},
status=status.HTTP_400_BAD_REQUEST
)
order.status = 'cancelled'
order.save(update_fields=['status', 'updated_at'])
return Response({'detail': 'Order cancelled.'})
get_queryset() with select_related() and prefetch_related() is how you eliminate N+1 queries in Django. select_related() performs a SQL JOIN for single-valued relationships (ForeignKey, OneToOne). prefetch_related() runs a separate query and does the join in Python for many-to-many and reverse FK relationships. Without these, listing 20 products with their categories would run 21 queries: one to get the products and one per product to get its category.
get_serializer_class() returns different serializers for different actions. The list view gets a lightweight serializer. The detail view gets the full serializer. Create and update get a write-specific serializer with custom validation. This separation keeps your serializers focused and testable.
Custom @action decorators add extra endpoints to your viewsets. detail=True means the endpoint operates on a specific record (/products/my-product/reviews/). detail=False means it operates on the collection (/products/featured/).
URL routing:
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from . import views
router = DefaultRouter()
router.register('categories', views.CategoryViewSet, basename='category')
router.register('products', views.ProductViewSet, basename='product')
router.register('orders', views.OrderViewSet, basename='order')
urlpatterns = [
path('api/v1/', include(router.urls)),
]
The router generates URL patterns for every standard action on each viewset automatically. ProductViewSet with lookup_field = 'slug' generates:
GET /api/v1/products/- listPOST /api/v1/products/- createGET /api/v1/products/{slug}/- retrievePUT /api/v1/products/{slug}/- updatePATCH /api/v1/products/{slug}/- partial_updateDELETE /api/v1/products/{slug}/- destroyGET /api/v1/products/{slug}/reviews/- reviews actionPOST /api/v1/products/{slug}/reviews/- create_review action
Custom permissions keep authorization logic out of views:
from rest_framework import permissions
class IsOwnerOrReadOnly(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
if request.method in permissions.SAFE_METHODS:
return True
return obj.user == request.user
class IsAdminOrReadOnly(permissions.BasePermission):
def has_permission(self, request, view):
if request.method in permissions.SAFE_METHODS:
return True
return request.user and request.user.is_staff
SAFE_METHODS is ('GET', 'HEAD', 'OPTIONS'). Read operations are open. Write operations require ownership or staff status.
The ORM Query Interface
Django's QuerySet API is what you spend most of your time with. Here is what production query patterns look like:
from django.db.models import Q, F, Avg, Count, Sum, Window
from django.db.models.functions import Rank, TruncMonth
from django.utils import timezone
from datetime import timedelta
from .models import Product, Order, OrderItem
def get_product_analytics(days: int = 30) -> list[dict]:
since = timezone.now() - timedelta(days=days)
return list(
Product.objects.filter(status='active').annotate(
avg_rating=Avg('reviews__rating'),
review_count=Count('reviews', distinct=True),
total_sold=Sum('order_items__quantity'),
revenue=Sum(F('order_items__quantity') * F('order_items__unit_price'))
).filter(
Q(total_sold__isnull=False) | Q(review_count__gt=0)
).select_related('category').order_by('-revenue').values(
'id', 'name', 'slug', 'price', 'stock',
'avg_rating', 'review_count', 'total_sold', 'revenue',
'category__name'
)[:50]
)
def get_monthly_revenue(year: int) -> list[dict]:
return list(
Order.objects.filter(
status='delivered',
created_at__year=year
).annotate(
month=TruncMonth('created_at')
).values('month').annotate(
order_count=Count('id'),
total_revenue=Sum('total_amount'),
avg_order_value=Avg('total_amount')
).order_by('month')
)
def search_products(query: str, category_slug: str = None,
min_price: float = None, max_price: float = None,
in_stock_only: bool = False) -> 'QuerySet':
filters = Q(status='active') & (
Q(name__icontains=query) |
Q(description__icontains=query) |
Q(category__name__icontains=query)
)
if category_slug:
filters &= Q(category__slug=category_slug)
if min_price is not None:
filters &= Q(price__gte=min_price)
if max_price is not None:
filters &= Q(price__lte=max_price)
if in_stock_only:
filters &= Q(stock__gt=0)
return Product.objects.filter(filters).annotate(
avg_rating=Avg('reviews__rating'),
review_count=Count('reviews')
).select_related('category').order_by('-avg_rating', '-review_count')
Q objects let you combine filter conditions with & (AND), | (OR), and ~ (NOT). F objects reference other fields in the same row, letting you do math between columns in the database instead of in Python. annotate() adds computed columns to each row in the queryset, calculated in SQL.
TruncMonth rounds timestamps down to the month boundary, letting you group time-series data by month in a single query.
Who Runs Django
Instagram is the canonical example. Their engineering team has published detailed technical writing about running Django at scale. The key insight from their posts is that they do not fight Django. They optimize at the database layer, at the caching layer, and at the infrastructure layer. The framework itself handles correctly.
Mozilla runs Django across several of their web properties including addons.mozilla.org, which serves millions of Firefox users. Disqus was an early high-profile Django deployment that wrote publicly about scaling Django to handle high comment volumes. Bitbucket ran Django for years before being acquired by Atlassian. Pinterest built their initial infrastructure on Django before eventually migrating parts to internal systems.
The Washington Post's news publishing platform runs Django. The pattern across these organizations is the same: Django's built-in tools handle a lot of what custom code would otherwise have to solve.
Django vs Flask vs FastAPI
These three frameworks are not competing for the same use case.
Django makes sense when your project has complex data relationships, when non-technical team members need to manage data through an admin interface, when you want authentication and migrations provided for you, and when you are building a content-heavy or e-commerce application. The framework gives you 80% of what you need.
Flask makes sense when you need a lightweight service, when you want to compose your stack yourself, or when you need to integrate with systems that Django's conventions would complicate. Flask's small surface area means less to learn and less to fight.
FastAPI makes sense when performance under load matters, when you want auto-generated API documentation, when your codebase uses type hints throughout, and when async is a first-class requirement. FastAPI's dependency injection system and Pydantic integration produce some of the cleanest API code in Python.
The wrong answer is agonizing over the choice when you should be building. Django's admin panel alone makes it worth choosing for any project where non-developers will touch data. That is most projects.
Django Signals
Signals let decoupled parts of an application get notified when certain events happen. The most common use cases are sending confirmation emails when a user registers, creating related records when a parent record is created, and invalidating caches when data changes.
from django.db.models.signals import post_save, pre_delete, m2m_changed
from django.dispatch import receiver, Signal
from django.contrib.auth import get_user_model
User = get_user_model()
order_status_changed = Signal()
@receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs):
if created:
from .models import UserProfile
UserProfile.objects.create(user=instance)
@receiver(post_save, sender=User)
def send_welcome_email(sender, instance, created, **kwargs):
if created:
from .tasks import send_welcome_email_task
send_welcome_email_task.delay(instance.id)
@receiver(order_status_changed)
def notify_user_on_status_change(sender, order, old_status, new_status, **kwargs):
if new_status == 'shipped':
from .tasks import send_shipping_notification
send_shipping_notification.delay(order.id)
def connect_signals():
pass
Register signals in your app's AppConfig.ready():
from django.apps import AppConfig
class ApiConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'api'
def ready(self):
import api.signals
post_save fires after a model instance is saved. created=True means the record was just created, not updated. Checking created is essential for signals that should only run on new records. Without the check, sending a welcome email on every save would spam users every time their profile gets updated.
The order_status_changed signal is custom. You send it explicitly from your code when the order status changes:
def update_order_status(order, new_status):
old_status = order.status
order.status = new_status
order.save(update_fields=['status', 'updated_at'])
if old_status != new_status:
order_status_changed.send(
sender=Order,
order=order,
old_status=old_status,
new_status=new_status
)
Custom signals are explicit. They document the points in your application where important state transitions happen. A developer reading your codebase can search for order_status_changed.send( and find every place that transition is triggered.
Background Tasks with Celery
Long-running operations do not belong in HTTP request handlers. Sending emails, processing images, making external API calls, generating reports: all of these should run asynchronously in background workers. Celery is the standard task queue for Django.
import os
from celery import Celery
from django.conf import settings
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myapp.settings')
app = Celery('myapp')
app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks()
Add to settings.py:
CELERY_BROKER_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379/0')
CELERY_RESULT_BACKEND = os.environ.get('REDIS_URL', 'redis://localhost:6379/0')
CELERY_ACCEPT_CONTENT = ['json']
CELERY_TASK_SERIALIZER = 'json'
CELERY_RESULT_SERIALIZER = 'json'
CELERY_TIMEZONE = 'UTC'
CELERY_TASK_TRACK_STARTED = True
CELERY_TASK_TIME_LIMIT = 300
CELERY_TASK_SOFT_TIME_LIMIT = 240
Tasks:
from celery import shared_task
from celery.utils.log import get_task_logger
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.contrib.auth import get_user_model
import requests
logger = get_task_logger(__name__)
User = get_user_model()
@shared_task(
bind=True,
max_retries=3,
default_retry_delay=60,
autoretry_for=(Exception,),
retry_backoff=True,
retry_backoff_max=300,
retry_jitter=True
)
def send_welcome_email_task(self, user_id: int) -> dict:
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist:
logger.warning(f"User {user_id} not found for welcome email")
return {'status': 'skipped', 'reason': 'user_not_found'}
html_content = render_to_string('emails/welcome.html', {'user': user})
send_mail(
subject='Welcome to MyApp',
message=f'Welcome {user.username}!',
from_email='[email protected]',
recipient_list=[user.email],
html_message=html_content,
fail_silently=False
)
logger.info(f"Welcome email sent to {user.email}")
return {'status': 'sent', 'email': user.email}
@shared_task(
bind=True,
max_retries=5,
default_retry_delay=30,
queue='high_priority'
)
def send_shipping_notification(self, order_id: int) -> dict:
from .models import Order
try:
order = Order.objects.select_related('user').get(id=order_id)
except Order.DoesNotExist:
return {'status': 'skipped', 'reason': 'order_not_found'}
send_mail(
subject=f'Your order #{order.id} has shipped',
message=f'Good news! Your order has been shipped.',
from_email='[email protected]',
recipient_list=[order.user.email],
fail_silently=False
)
return {'status': 'sent', 'order_id': order_id}
@shared_task(
bind=True,
max_retries=3,
time_limit=120,
soft_time_limit=90
)
def process_product_image(self, product_id: int, image_url: str) -> dict:
from PIL import Image
from io import BytesIO
from django.core.files.base import ContentFile
from .models import Product
try:
response = requests.get(image_url, timeout=30)
response.raise_for_status()
except requests.RequestException as exc:
logger.error(f"Failed to download image for product {product_id}: {exc}")
raise self.retry(exc=exc)
try:
img = Image.open(BytesIO(response.content))
img.thumbnail((800, 800), Image.LANCZOS)
output = BytesIO()
img.save(output, format='WEBP', quality=85, optimize=True)
output.seek(0)
product = Product.objects.get(id=product_id)
product.image.save(
f'product_{product_id}.webp',
ContentFile(output.read()),
save=True
)
return {'status': 'processed', 'product_id': product_id}
except Exception as exc:
logger.error(f"Failed to process image for product {product_id}: {exc}")
raise self.retry(exc=exc)
bind=True gives the task access to self, which lets you call self.retry() with the original exception. autoretry_for=(Exception,) with retry_backoff=True automatically retries failed tasks with exponential backoff. The task waits 60 seconds before the first retry, then 120 seconds, then 240 seconds. retry_jitter=True adds randomness to retry delays, preventing a thundering herd when many tasks fail simultaneously and all retry at the same moment.
Run Celery workers:
celery -A myapp worker --loglevel=info --concurrency=4
celery -A myapp worker --loglevel=info --queues=high_priority --concurrency=2
celery -A myapp beat --loglevel=info
Celery Beat runs periodic tasks on a schedule. Add scheduled tasks to settings.py:
from celery.schedules import crontab
CELERY_BEAT_SCHEDULE = {
'cleanup-expired-tokens': {
'task': 'api.tasks.cleanup_expired_tokens',
'schedule': crontab(hour=2, minute=0),
},
'send-daily-digest': {
'task': 'api.tasks.send_daily_digest',
'schedule': crontab(hour=8, minute=0),
},
}
Caching
Django's cache framework supports multiple backends. Redis is the standard choice for production because it supports cache invalidation patterns that in-memory caching cannot.
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
'LOCATION': os.environ.get('REDIS_URL', 'redis://localhost:6379/1'),
'OPTIONS': {
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
},
'KEY_PREFIX': 'myapp',
'TIMEOUT': 300,
}
}
Cache usage in views:
from django.core.cache import cache
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page
from rest_framework.response import Response
from rest_framework.decorators import api_view
def get_product_analytics_cached(days: int = 30) -> list[dict]:
cache_key = f'product_analytics_{days}d'
result = cache.get(cache_key)
if result is None:
result = get_product_analytics(days)
cache.set(cache_key, result, timeout=3600)
return result
def invalidate_product_cache(product_id: int) -> None:
patterns = [
f'product_detail_{product_id}',
'product_analytics_30d',
'product_analytics_7d',
'product_list_*',
]
for pattern in patterns:
cache.delete(pattern)
@api_view(['GET'])
def analytics_view(request):
days = int(request.query_params.get('days', 30))
data = get_product_analytics_cached(days)
return Response(data)
Testing with pytest-django
import pytest
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from django.contrib.auth import get_user_model
from .models import Product, Category, Order
User = get_user_model()
@pytest.fixture
def api_client():
return APIClient()
@pytest.fixture
def user(db):
return User.objects.create_user(
username='testuser',
email='[email protected]',
password='testpassword123'
)
@pytest.fixture
def admin_user(db):
return User.objects.create_superuser(
username='admin',
email='[email protected]',
password='adminpassword123'
)
@pytest.fixture
def authenticated_client(api_client, user):
api_client.force_authenticate(user=user)
return api_client
@pytest.fixture
def category(db):
return Category.objects.create(name='Electronics', slug='electronics')
@pytest.fixture
def product(db, category, user):
return Product.objects.create(
name='Test Product',
slug='test-product',
description='Test description',
price='29.99',
stock=100,
category=category,
created_by=user,
status='active'
)
@pytest.mark.django_db
class TestProductAPI:
def test_list_products(self, api_client, product):
url = reverse('product-list')
response = api_client.get(url)
assert response.status_code == status.HTTP_200_OK
assert len(response.data['results']) == 1
def test_create_product_requires_auth(self, api_client, category):
url = reverse('product-list')
response = api_client.post(url, {
'name': 'New Product',
'description': 'Description',
'price': '19.99',
'category': category.id,
})
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_create_product_requires_admin(self, authenticated_client, category):
url = reverse('product-list')
response = authenticated_client.post(url, {
'name': 'New Product',
'description': 'Description',
'price': '19.99',
'category': category.id,
})
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_filter_by_category(self, api_client, product, category):
url = reverse('product-list')
response = api_client.get(url, {'category__slug': 'electronics'})
assert response.status_code == status.HTTP_200_OK
assert len(response.data['results']) == 1
def test_search_products(self, api_client, product):
url = reverse('product-list')
response = api_client.get(url, {'search': 'Test'})
assert response.status_code == status.HTTP_200_OK
assert len(response.data['results']) == 1
response = api_client.get(url, {'search': 'nonexistent'})
assert response.status_code == status.HTTP_200_OK
assert len(response.data['results']) == 0
@pytest.mark.django_db
class TestOrderAPI:
def test_create_order(self, authenticated_client, product):
url = reverse('order-list')
response = authenticated_client.post(url, {
'shipping_address': {
'street': '123 Main St',
'city': 'Anytown',
'country': 'US',
'postal_code': '12345'
},
'items': [
{'product_id': product.id, 'quantity': 2}
]
}, format='json')
assert response.status_code == status.HTTP_201_CREATED
product.refresh_from_db()
assert product.stock == 98
def test_create_order_insufficient_stock(self, authenticated_client, product):
product.stock = 1
product.save()
url = reverse('order-list')
response = authenticated_client.post(url, {
'shipping_address': {'street': '123 Main St'},
'items': [{'product_id': product.id, 'quantity': 5}]
}, format='json')
assert response.status_code == status.HTTP_400_BAD_REQUEST
force_authenticate(user=user) bypasses the normal authentication flow for testing. You test the authentication system separately. In other tests, you assume authentication works and test the business logic.
product.refresh_from_db() reloads the model instance from the database. After the order is created and stock is reserved, the in-memory product object still has the old stock value. refresh_from_db() fetches the current state.
Django Is Django Is Django
One thing the entire ecosystem agrees on: Django's API is stable. A Django 2.0 tutorial from 2018 still mostly works in Django 5.0 today. The core team takes deprecation seriously and maintains backward compatibility across major versions with a two-version deprecation cycle.
That stability means a large body of existing tutorials, Stack Overflow answers, and documentation still applies. You are not learning a framework that will restructure its API next quarter.
Running Django in Production
The standard production setup runs Django behind a reverse proxy:
server {
listen 80;
server_name yourdomain.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name yourdomain.com;
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
location /static/ {
alias /var/www/myapp/staticfiles/;
expires 1y;
add_header Cache-Control "public, immutable";
}
location /media/ {
alias /var/www/myapp/media/;
}
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Nginx serves static files directly from disk. Django never sees those requests. The application server (Gunicorn or Uvicorn) handles only dynamic requests.
gunicorn myapp.wsgi:application \
--workers 4 \
--worker-class gthread \
--threads 4 \
--bind 127.0.0.1:8000 \
--max-requests 1000 \
--max-requests-jitter 100 \
--timeout 30 \
--access-logfile - \
--error-logfile -
Worker count is typically (2 * CPU_cores) + 1. --max-requests 1000 recycles workers after 1000 requests, which prevents memory leaks from accumulating indefinitely. --max-requests-jitter 100 adds randomness to the recycle threshold so workers do not all restart simultaneously.
For async Django with channels or with asgi.py, you swap Gunicorn for Uvicorn:
uvicorn myapp.asgi:application \
--workers 4 \
--bind 127.0.0.1:8000 \
--loop uvloop \
--http httptools
Django is a mature, production-proven framework that has been handling web traffic since 2005. The batteries it includes are not bloat. They are years of accumulated solutions to problems every web application eventually faces. Instagram scaled 2 billion users on it. That should settle most debates about whether it can handle what you are building.