Django performance cheatsheet.
Measure first
- Django Debug Toolbar: shows queries, time, cache hits.
- silk: detailed profiling.
- APM: New Relic, Datadog, Sentry Performance.
uv add --dev django-debug-toolbar
INSTALLED_APPS = [..., "debug_toolbar"]
MIDDLEWARE = ["debug_toolbar.middleware.DebugToolbarMiddleware", ...]
INTERNAL_IPS = ["127.0.0.1"]
Fix N+1
# BAD
for post in Post.objects.all():
print(post.author.name) # extra query per post
# GOOD
for post in Post.objects.select_related("author"):
print(post.author.name)
# For M2M / reverse FK
Post.objects.prefetch_related("tags", "comments")
Reduce field selection
# Load all fields (default)
Post.objects.all()
# Only specific fields
Post.objects.only("id", "title")
Post.objects.values("id", "title")
Post.objects.values_list("id", flat=True)
# Defer expensive fields
Post.objects.defer("body", "raw_html")
annotate vs Python
# BAD: compute in Python
posts = Post.objects.all()
for p in posts:
p.comment_count = p.comments.count() # extra query each
# GOOD: annotate in SQL
Post.objects.annotate(comment_count=Count("comments"))
Bulk operations
# BAD
for item in items:
Post.objects.create(title=item) # one INSERT each
# GOOD
Post.objects.bulk_create([Post(title=x) for x in items])
# Bulk update
Post.objects.filter(views=0).update(archived=True)
Post.objects.bulk_update(posts, ["title", "body"])
update vs save
# BAD: SELECT then UPDATE
post = Post.objects.get(id=1)
post.views += 1
post.save()
# GOOD: single UPDATE
Post.objects.filter(id=1).update(views=F("views") + 1)
Atomic, no race condition.
QuerySet caching
qs = Post.objects.all()
list(qs) # 1 query
list(qs) # 0 queries (cached)
qs[0] # 0 queries
# But:
qs.filter(x=1) # new QuerySet, no cache
iterator()
# Default: loads all rows into memory
for p in Post.objects.all(): ...
# Chunked iteration
for p in Post.objects.all().iterator(chunk_size=2000):
...
For huge tables to avoid OOM.
select_for_update
with transaction.atomic():
obj = Item.objects.select_for_update().get(id=1)
obj.stock -= 1
obj.save()
Prevents race conditions but locks the row. Use sparingly.
Indexes
class Post(models.Model):
...
class Meta:
indexes = [
models.Index(fields=["author", "-created_at"]),
models.Index(fields=["slug"]),
]
Add for fields you filter(), order_by(), JOIN on.
EXPLAIN to verify:
print(qs.explain())
Connection pooling (PgBouncer)
Django opens a new DB connection per request by default. With many workers, you’ll exhaust max_connections.
Use PgBouncer in transaction mode:
# pgbouncer.ini
[pgbouncer]
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 25
Django connects to PgBouncer at port 6432; PgBouncer multiplexes to Postgres.
CONN_MAX_AGE
DATABASES = {
"default": {
...,
"CONN_MAX_AGE": 600, # reuse connection for 10min
"CONN_HEALTH_CHECKS": True,
},
}
Reuse DB connections across requests.
CACHE expensive queries
from django.core.cache import cache
def top_posts():
key = "top_posts"
posts = cache.get(key)
if posts is None:
posts = list(Post.objects.filter(featured=True)[:10])
cache.set(key, posts, timeout=300)
return posts
Use cached_property
from django.utils.functional import cached_property
class Post(models.Model):
@cached_property
def comment_count(self):
return self.comments.count()
Per-instance memoization.
Async views for I/O-heavy
If most of your time is in external API calls, async + httpx parallelizes them:
async def view(request):
a, b, c = await asyncio.gather(
fetch_a(), fetch_b(), fetch_c(),
)
Template caching
TEMPLATES = [{
"OPTIONS": {
"loaders": [(
"django.template.loaders.cached.Loader",
["django.template.loaders.filesystem.Loader", "django.template.loaders.app_directories.Loader"],
)],
},
}]
Caches parsed templates between requests.
Gunicorn workers tuning
- Workers:
2 * cpus + 1. - Threads:
2-4for I/O bound. - For async/Channels: use uvicorn workers via
gunicorn -k uvicorn.workers.UvicornWorker.
Heavy serializers (DRF)
class PostListSerializer(serializers.ModelSerializer):
class Meta:
fields = ["id", "title", "author_name"] # minimal
class PostDetailSerializer(serializers.ModelSerializer):
class Meta:
fields = "__all__"
def get_serializer_class(self):
return PostListSerializer if self.action == "list" else PostDetailSerializer
Image processing
Don’t block request:
# In view
post.image = uploaded_file
post.save()
generate_thumbnails.delay(post.id) # async
# In task
def generate_thumbnails(post_id):
post = Post.objects.get(id=post_id)
# resize, store
Pagination
# Use cursor-based for huge data
class PostPagination(CursorPagination):
page_size = 50
ordering = "-id"
Faster than OFFSET on large tables.
Common bottlenecks
- N+1 queries (most common).
- Missing indexes.
- Loading too many fields.
- Synchronous external API calls.
- Template loops triggering DB.
- No connection pooling under load.
- Tight CPU loops in request path.
Common mistakes
- Optimizing without profiling.
len(qs)instead ofqs.count().if qsmaterializes everything; useqs.exists().Model.objects.filter(...).first()then comparing to None — fine, but lighter thantry/except DoesNotExistin hot path.- Caching mutable objects without copying.
Read this next
If you want my Django perf checklist + middleware, it’s at rajpoot.dev .
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .