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-4 for 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

  1. N+1 queries (most common).
  2. Missing indexes.
  3. Loading too many fields.
  4. Synchronous external API calls.
  5. Template loops triggering DB.
  6. No connection pooling under load.
  7. Tight CPU loops in request path.

Common mistakes

  • Optimizing without profiling.
  • len(qs) instead of qs.count().
  • if qs materializes everything; use qs.exists().
  • Model.objects.filter(...).first() then comparing to None — fine, but lighter than try/except DoesNotExist in 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 .