DEV Community

Cover image for Making Django 10x Faster: The Performance Engineering Playbook
Derek Mwale
Derek Mwale

Posted on

Making Django 10x Faster: The Performance Engineering Playbook

There is a point in almost every Django project where the application starts behaving differently.

At 100 users, everything feels instant.

At 1,000 users, you notice a few slow pages.

At 10,000 users, someone opens a monitoring dashboard and says:

“Why is this endpoint taking 1.8 seconds?”

Then somebody adds Redis.

Then somebody adds more Gunicorn workers.

Then somebody increases the database server.

Then somebody adds Nginx caching.

And somehow, three weeks later, the application is still slow.

This is one of the most interesting things about performance engineering:

A slow application is rarely caused by one slow thing.

It is usually caused by a chain of small decisions.

A query that takes 40 ms.

A serializer that performs another query.

A template that triggers lazy evaluation.

A loop that performs 500 database calls.

A missing database index.

A giant JSON response.

A synchronous external API call.

A badly configured connection pool.

A cache that is never actually hit.

Each problem looks harmless by itself.

Together, they create a system that feels broken.

The good news is that Django is already an extremely capable framework. You usually don't need to abandon Django, rewrite everything in Rust, or replace your database.

You need to understand what your application is actually doing.

And once you do that, making Django dramatically faster becomes less about magic and more about engineering.

This is how I think about it:

                 Django Performance

                       Request
                          |
                          v
                 +----------------+
                 |     Nginx      |
                 +----------------+
                          |
                          v
                 +----------------+
                 |    Gunicorn    |
                 +----------------+
                          |
                          v
                 +----------------+
                 |     Django     |
                 |                |
                 | Middleware     |
                 | Views          |
                 | Serializers    |
                 | Templates      |
                 +----------------+
                    /          \
                   /            \
                  v              v
          +-------------+   +----------+
          | PostgreSQL  |   |  Redis   |
          +-------------+   +----------+
                 |
                 v
          External Services
Enter fullscreen mode Exit fullscreen mode

The objective isn't simply to make Python execute faster.

The objective is to reduce the amount of work required to complete a request.

That's a completely different mindset.


1. First: Stop Guessing

The biggest performance mistake developers make is optimizing code before measuring it.

Someone says:

“Django is slow.”

That's not a diagnosis.

Which part is slow?

The database?

Python?

Serialization?

Network latency?

Template rendering?

External APIs?

Lock contention?

Connection establishment?

Garbage collection?

CPU saturation?

Memory pressure?

Disk I/O?

You cannot optimize a mystery.

Start with measurements.

A useful mental model is:

Request latency
     =
network
+
middleware
+
application code
+
database
+
external services
+
serialization
+
response generation
Enter fullscreen mode Exit fullscreen mode

If your endpoint takes 1,000 ms and PostgreSQL consumes 850 ms, optimizing Python by 50% changes almost nothing.

You reduced 150 ms to 75 ms.

Total:

850 + 75 = 925 ms
Enter fullscreen mode Exit fullscreen mode

Congratulations.

You spent six hours making the application 7.5% faster.

But if you optimize the database from 850 ms to 100 ms:

100 + 150 = 250 ms
Enter fullscreen mode Exit fullscreen mode

Now you've achieved something meaningful.

This is why profiling comes first.


2. Understand the Django Request

Before optimizing Django, understand what happens during a request.

A simplified request lifecycle looks like this:

HTTP Request
     |
     v
Web Server
     |
     v
Middleware
     |
     v
URL Resolver
     |
     v
View
     |
     +------> ORM ------> Database
     |
     +------> Cache
     |
     +------> External APIs
     |
     v
Serializer / Template
     |
     v
Middleware
     |
     v
HTTP Response
Enter fullscreen mode Exit fullscreen mode

Every arrow represents potential latency.

Suppose your endpoint does this:

def dashboard(request):
    users = User.objects.all()
    orders = Order.objects.all()
    stats = requests.get("https://analytics.example.com/stats")

    return JsonResponse({
        "users": list(users),
        "orders": list(orders),
        "stats": stats.json(),
    })
Enter fullscreen mode Exit fullscreen mode

It looks innocent.

But you've potentially created:

  1. A database query for users.
  2. A database query for orders.
  3. A network request to another server.
  4. Serialization work.
  5. JSON encoding.
  6. Memory allocation.
  7. Network transfer.

And because the external request is synchronous, Django waits.

The browser waits.

The user waits.

Everything waits.

Performance engineering begins by asking:

What work can I remove?

That question is more powerful than:

How can I execute this work faster?


3. The Biggest Django Performance Killer: N+1 Queries

If there is one Django optimization that developers should memorize, it is this:

Watch your query count.

Consider:

orders = Order.objects.all()

for order in orders:
    print(order.customer.name)
Enter fullscreen mode Exit fullscreen mode

If there are 1,000 orders, you might accidentally generate:

1 query
+
1,000 customer queries
=
1,001 queries
Enter fullscreen mode Exit fullscreen mode

This is the infamous N+1 problem.

The solution is often:

orders = Order.objects.select_related("customer")
Enter fullscreen mode Exit fullscreen mode

Now Django can fetch the related object efficiently.

Conceptually:

Bad:

Order 1 ---> Customer query
Order 2 ---> Customer query
Order 3 ---> Customer query
Order 4 ---> Customer query
...
Order 1000 -> Customer query


Good:

             +----------------------+
             |      One Query       |
             | orders + customers   |
             +----------------------+
                       |
                       v
                 Application
Enter fullscreen mode Exit fullscreen mode

For foreign keys and one-to-one relationships, select_related() is often appropriate.

For many-to-many and reverse relationships, use prefetch_related().

Example:

orders = (
    Order.objects
    .select_related("customer")
    .prefetch_related("items")
)
Enter fullscreen mode Exit fullscreen mode

Instead of:

Orders query
Customer query
Items query
Items query
Items query
...
Enter fullscreen mode Exit fullscreen mode

you can reduce the database work dramatically.

This isn't a micro-optimization.

On real applications, eliminating N+1 queries can turn a painfully slow endpoint into a fast one.


4. Don't Fetch Columns You Don't Need

Developers frequently write:

users = User.objects.all()
Enter fullscreen mode Exit fullscreen mode

when they only need:

id
username
email
Enter fullscreen mode Exit fullscreen mode

If your model contains:

id
username
email
bio
avatar
address
preferences
metadata
created_at
updated_at
...
Enter fullscreen mode Exit fullscreen mode

you're potentially moving unnecessary data from PostgreSQL into Python.

Use:

users = User.objects.only(
    "id",
    "username",
    "email",
)
Enter fullscreen mode Exit fullscreen mode

Or use:

users = User.objects.values(
    "id",
    "username",
    "email",
)
Enter fullscreen mode Exit fullscreen mode

The second approach is particularly useful when you don't need full model instances.

For example:

users = list(
    User.objects
    .filter(is_active=True)
    .values("id", "username")
)
Enter fullscreen mode Exit fullscreen mode

You are telling the database:

Give me exactly what I need.

That principle is incredibly important.


5. Stop Using len(queryset) When You Mean count()

Suppose you want to know how many users exist.

Don't do:

users = User.objects.filter(is_active=True)

if len(users) > 100:
    ...
Enter fullscreen mode Exit fullscreen mode

That can force Django to load the queryset.

Use:

if users.count() > 100:
    ...
Enter fullscreen mode Exit fullscreen mode

Even better, if you only care whether something exists:

if users.exists():
    ...
Enter fullscreen mode Exit fullscreen mode

Because:

if len(queryset):
Enter fullscreen mode Exit fullscreen mode

and:

if queryset.exists():
Enter fullscreen mode Exit fullscreen mode

are conceptually different operations.

The second asks the database a much cheaper question:

Does at least one row exist?

You don't need to download an entire dataset just to answer yes or no.


6. Database Indexes Are Performance Superpowers

Imagine a table containing 20 million users.

You execute:

User.objects.filter(email="derek@example.com").first()
Enter fullscreen mode Exit fullscreen mode

If email isn't indexed, PostgreSQL may need to inspect a huge portion of the table.

With an index:

                 Query

                   |
                   v

          +----------------+
          | Email Index    |
          +----------------+
                   |
                   v
             Matching row
Enter fullscreen mode Exit fullscreen mode

instead of:

Query
  |
  v
Row 1
Row 2
Row 3
Row 4
...
Row 20,000,000
Enter fullscreen mode Exit fullscreen mode

You can define indexes in Django:

class User(models.Model):
    email = models.EmailField(unique=True)

    class Meta:
        indexes = [
            models.Index(fields=["email"]),
        ]
Enter fullscreen mode Exit fullscreen mode

If you frequently filter by:

status
Enter fullscreen mode Exit fullscreen mode

consider an index.

If you frequently query:

user_id, created_at
Enter fullscreen mode Exit fullscreen mode

a composite index may be useful:

models.Index(
    fields=["user", "created_at"]
)
Enter fullscreen mode Exit fullscreen mode

But don't blindly index everything.

Indexes have costs.

They consume storage.

They slow writes.

They increase maintenance overhead.

The correct question isn't:

“Should I index this column?”

It's:

“What query patterns does my application actually perform?”


7. Learn to Read SQL

Django's ORM is wonderful.

But abstraction can become dangerous when you stop understanding what happens underneath it.

This:

Order.objects.filter(
    customer__email=email,
    status="completed"
)
Enter fullscreen mode Exit fullscreen mode

eventually becomes SQL.

You should know how to inspect that SQL.

For development:

print(
    Order.objects
    .filter(status="completed")
    .query
)
Enter fullscreen mode Exit fullscreen mode

You can also use PostgreSQL's query analysis tools.

For example:

EXPLAIN ANALYZE
SELECT *
FROM orders
WHERE status = 'completed';
Enter fullscreen mode Exit fullscreen mode

Now you're no longer guessing.

You're seeing what the database actually did.

You may discover:

Seq Scan
Enter fullscreen mode Exit fullscreen mode

when you expected:

Index Scan
Enter fullscreen mode Exit fullscreen mode

That's the moment performance engineering becomes real.


8. Optimize the Query, Not Just Django

Sometimes the ORM isn't the problem.

The query itself is.

Suppose you're calculating:

orders = Order.objects.filter(
    created_at__gte=start,
    created_at__lte=end,
)
Enter fullscreen mode Exit fullscreen mode

and then Python performs:

total = 0

for order in orders:
    total += order.amount
Enter fullscreen mode Exit fullscreen mode

Why transfer thousands of rows into Python?

Let PostgreSQL perform the aggregation.

from django.db.models import Sum

total = (
    Order.objects
    .filter(
        created_at__gte=start,
        created_at__lte=end,
    )
    .aggregate(total=Sum("amount"))
)
Enter fullscreen mode Exit fullscreen mode

Now:

Before:

Database
   |
   | thousands of rows
   v
Python
   |
   v
Sum


After:

Database
   |
   | SUM(amount)
   v
One result
Enter fullscreen mode Exit fullscreen mode

Databases are extremely good at data aggregation.

Use them.


9. Cache Expensive Work

Not everything needs to be calculated repeatedly.

Imagine your homepage calculates:

10,000 products
500 categories
Trending products
Popular searches
Recommended products
Statistics
Enter fullscreen mode Exit fullscreen mode

and every visitor causes the same calculations.

That's wasteful.

A cache changes the architecture:

Request
   |
   v
Redis
   |
   +---- HIT ----> Return cached result
   |
   +---- MISS
          |
          v
       Django
          |
          v
       Database
          |
          v
       Store in Redis
Enter fullscreen mode Exit fullscreen mode

With Django's cache framework:

from django.core.cache import cache

data = cache.get("homepage")

if data is None:
    data = build_homepage()
    cache.set("homepage", data, timeout=300)
Enter fullscreen mode Exit fullscreen mode

Now thousands of requests may avoid the database entirely.

But caching isn't free.

You now have a new problem:

Cache invalidation.

Suppose you cache:

product:123
Enter fullscreen mode Exit fullscreen mode

for 24 hours.

Then someone changes the product price.

Your users may see the old price.

So caching requires thinking about:

  • expiration
  • invalidation
  • consistency
  • cache keys
  • memory usage
  • stampedes

Caching is powerful because you're essentially saying:

I am willing to trade perfect freshness for lower computation.

For many read-heavy systems, that's a fantastic trade.


10. Cache at Multiple Layers

Caching doesn't have to exist only inside Django.

You can have:

Browser Cache
      |
      v
CDN
      |
      v
Reverse Proxy
      |
      v
Django Cache
      |
      v
Database
Enter fullscreen mode Exit fullscreen mode

The further up the stack a request can be served, the less work your application performs.

Imagine a static image requested 100,000 times.

You don't want:

100,000 requests
        |
        v
Django
Enter fullscreen mode Exit fullscreen mode

You want:

100,000 requests
        |
        v
CDN
        |
        v
Cached asset
Enter fullscreen mode Exit fullscreen mode

Django shouldn't be processing things it doesn't need to process.


11. Pagination Is Not Optional

This is dangerous:

posts = Post.objects.all()
Enter fullscreen mode Exit fullscreen mode

Then returning everything:

return JsonResponse({
    "posts": list(posts)
})
Enter fullscreen mode Exit fullscreen mode

Today there are 500 posts.

Next year there are 500,000.

Your endpoint quietly becomes a disaster.

Use pagination.

Django provides pagination primitives:

from django.core.paginator import Paginator

posts = Post.objects.order_by("-created_at")

paginator = Paginator(posts, 50)

page = paginator.get_page(request.GET.get("page"))
Enter fullscreen mode Exit fullscreen mode

Now the application only processes a manageable slice.

The architecture becomes:

500,000 records
       |
       v
Database
       |
       v
50 records
       |
       v
Django
       |
       v
Client
Enter fullscreen mode Exit fullscreen mode

The client rarely needs half a million objects.


12. Don't Return Giant JSON Responses

A backend can be computationally fast and still feel slow.

Why?

Because network transfer matters.

Imagine your API returns:

{
  "users": [
    {
      "id": 1,
      "username": "...",
      "bio": "...",
      "metadata": "...",
      "history": "...",
      "preferences": "...",
      "orders": [...]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

If the frontend only needs:

{
  "id": 1,
  "username": "derek"
}
Enter fullscreen mode Exit fullscreen mode

everything else is wasted bandwidth.

Good API design is performance engineering.

Return only what the client needs.


13. Be Careful With Serializers

Django REST Framework makes API development incredibly productive.

But serializers can hide expensive work.

For example:

class OrderSerializer(serializers.ModelSerializer):
    customer_name = serializers.CharField(
        source="customer.name"
    )

    class Meta:
        model = Order
        fields = [
            "id",
            "amount",
            "customer_name",
        ]
Enter fullscreen mode Exit fullscreen mode

If you serialize 1,000 orders without:

.select_related("customer")
Enter fullscreen mode Exit fullscreen mode

you can accidentally create an N+1 problem.

So your API code should think in pairs:

Serializer
    +
Queryset optimization
Enter fullscreen mode Exit fullscreen mode

For example:

queryset = (
    Order.objects
    .select_related("customer")
    .prefetch_related("items")
)
Enter fullscreen mode Exit fullscreen mode

Then:

serializer = OrderSerializer(
    queryset,
    many=True
)
Enter fullscreen mode Exit fullscreen mode

The serializer should not be forced to repeatedly discover related objects through database calls.


14. Don't Block Django With External APIs

This is one of the easiest ways to make a fast backend feel slow.

Consider:

def dashboard(request):
    response = requests.get(
        "https://api.example.com/data"
    )

    return JsonResponse(response.json())
Enter fullscreen mode Exit fullscreen mode

If the external service takes 2 seconds, your request takes roughly 2 seconds.

Your infrastructure might be perfect.

Your database might be perfect.

Your Python code might be perfect.

You're still waiting.

For independent work, consider asynchronous execution or background jobs.

Architecture:

HTTP Request
     |
     v
Django
     |
     +----> Queue ----> Worker ----> External API
     |
     v
Immediate Response
Enter fullscreen mode Exit fullscreen mode

Tools such as Celery or other task queues can move slow work outside the request lifecycle.

For example:

send_report.delay(user_id)
Enter fullscreen mode Exit fullscreen mode

instead of:

send_report(user_id)
Enter fullscreen mode Exit fullscreen mode

The difference is architectural.


15. Async Django Is Useful—But Don't Worship Async

Django supports asynchronous views.

You can write:

async def dashboard(request):
    ...
Enter fullscreen mode Exit fullscreen mode

This can be valuable when your workload is I/O-heavy.

For example:

Request
   |
   +----> API A
   |
   +----> API B
   |
   +----> API C
Enter fullscreen mode Exit fullscreen mode

If those operations can be performed concurrently, asynchronous programming can reduce waiting.

Conceptually:

Synchronous:

API A: |------|
API B:        |------|
API C:              |------|

Total: ~3 seconds


Concurrent:

API A: |------|
API B: |------|
API C: |------|

Total: ~1 second
Enter fullscreen mode Exit fullscreen mode

But async doesn't magically make CPU-bound Python faster.

If you're doing expensive computation:

for i in range(100_000_000):
    calculate(i)
Enter fullscreen mode Exit fullscreen mode

making the function async doesn't suddenly make the CPU execute faster.

Async is primarily about efficiently managing waiting.


16. Gunicorn Workers Matter

Eventually you need to think about how Django processes requests.

A common deployment architecture looks like:

                Internet
                   |
                   v
                Nginx
                   |
          +--------+--------+
          |        |        |
          v        v        v
       Worker   Worker   Worker
          |        |        |
          +--------+--------+
                   |
                   v
              PostgreSQL
Enter fullscreen mode Exit fullscreen mode

Gunicorn workers allow multiple requests to be processed concurrently.

But:

More workers does not automatically mean more performance.

If you create too many workers:

CPU
RAM
Database connections
Enter fullscreen mode Exit fullscreen mode

can become bottlenecks.

Suppose each worker consumes significant memory.

If you launch 100 workers on a small server, you may create:

RAM exhaustion
        |
        v
Swapping
        |
        v
Everything becomes slower
Enter fullscreen mode Exit fullscreen mode

Performance tuning is about balance.

Measure:

  • CPU utilization
  • memory usage
  • request latency
  • worker saturation
  • database connections

Then adjust.


17. Database Connections Can Become the Hidden Bottleneck

Imagine:

50 Django workers
       |
       v
50 database connections
Enter fullscreen mode Exit fullscreen mode

Then you scale horizontally:

10 application instances
       |
       v
500 potential connections
       |
       v
PostgreSQL
Enter fullscreen mode Exit fullscreen mode

Now your database becomes the bottleneck.

This is why horizontal scaling isn't simply:

More servers = more performance
Enter fullscreen mode Exit fullscreen mode

A distributed system is a network of constraints.

You may have:

CPU
 |
 +--- Memory
 |
 +--- PostgreSQL
 |
 +--- Redis
 |
 +--- Network
 |
 +--- External APIs
Enter fullscreen mode Exit fullscreen mode

The weakest component controls the system.


18. Use Connection Pooling Carefully

Opening database connections repeatedly has overhead.

Connection reuse can help.

Django supports persistent database connections through configuration such as:

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": "...",
        "USER": "...",
        "PASSWORD": "...",
        "HOST": "...",
        "PORT": "...",
        "CONN_MAX_AGE": 60,
    }
}
Enter fullscreen mode Exit fullscreen mode

The exact configuration should depend on your deployment architecture.

At larger scale, dedicated connection pooling can become useful.

But again:

Don't add pooling because somebody on the internet said your application needs it.

Measure connection overhead and database saturation first.


19. Use bulk_create() Instead of Thousands of Inserts

Suppose you need to create 10,000 objects.

This:

for item in items:
    Product.objects.create(
        name=item["name"]
    )
Enter fullscreen mode Exit fullscreen mode

can generate thousands of database operations.

Instead:

products = [
    Product(name=item["name"])
    for item in items
]

Product.objects.bulk_create(
    products,
    batch_size=1000
)
Enter fullscreen mode Exit fullscreen mode

Now you can dramatically reduce database round trips.

The same principle applies to updates.

Instead of performing thousands of independent operations, look for opportunities to batch them.

Database performance is often about reducing trips.


20. Transactions Should Be Deliberate

Transactions are essential for correctness.

But long transactions can create contention.

Consider:

with transaction.atomic():
    do_expensive_calculation()
    call_external_api()
    update_database()
Enter fullscreen mode Exit fullscreen mode

This is dangerous architecture.

You are potentially holding database resources while waiting for an external network request.

A better architecture is generally:

Calculate
   |
   v
External operation
   |
   v
Short database transaction
   |
   v
Commit
Enter fullscreen mode Exit fullscreen mode

The goal is to keep critical database transactions as short as practical.

Performance and correctness aren't enemies.

Good transaction design improves both.


21. Optimize Templates Too

Django isn't only an API framework.

Traditional Django applications render templates.

Suppose you have:

{% for product in products %}
    {{ product.category.name }}
{% endfor %}
Enter fullscreen mode Exit fullscreen mode

If the queryset isn't optimized, your template can indirectly trigger database queries.

The solution isn't to blame templates.

Fix the queryset:

products = Product.objects.select_related("category")
Enter fullscreen mode Exit fullscreen mode

Templates should ideally operate on data that has already been prepared efficiently.

Think of rendering as the final stage:

Database
   |
   v
Optimized Queryset
   |
   v
Business Logic
   |
   v
Template
   |
   v
HTML
Enter fullscreen mode Exit fullscreen mode

22. Static Files Should Not Be Served by Django

Your application server should focus on dynamic requests.

Things like:

CSS
JavaScript
Images
Fonts
Videos
Enter fullscreen mode Exit fullscreen mode

should generally be served through appropriate static/media infrastructure.

A common architecture is:

                    Internet
                       |
                       v
                      CDN
                    /     \
                   /       \
              Static      Dynamic
                |            |
                v            v
             Assets       Django
Enter fullscreen mode Exit fullscreen mode

If Django spends resources serving a 4 MB image, you're using expensive application capacity for a task better handled by a CDN or object-storage layer.


23. Compression Matters

If your API returns large JSON responses, compression can significantly reduce transfer size.

For example:

Uncompressed:
2.0 MB

Compressed:
300 KB
Enter fullscreen mode Exit fullscreen mode

The exact ratio depends on the content.

This matters especially for:

  • mobile users
  • international users
  • high-latency networks
  • large API responses

Performance isn't only CPU.

It's also physics.

A byte has to travel from somewhere to somewhere else.


24. Use the Right Data Structure

Sometimes the database isn't the bottleneck.

Python itself can be.

For example:

users = []

for user in queryset:
    if user.id not in existing_ids:
        users.append(user)
Enter fullscreen mode Exit fullscreen mode

If existing_ids is a list, membership checks can become increasingly expensive.

A set:

existing_ids = set(existing_ids)
Enter fullscreen mode Exit fullscreen mode

can make membership checks dramatically cheaper.

This sounds small.

But imagine:

100,000 objects
Enter fullscreen mode Exit fullscreen mode

inside nested loops.

Small algorithmic mistakes become large performance problems.

The difference between:

O(n)
Enter fullscreen mode Exit fullscreen mode

and:

O(n²)
Enter fullscreen mode Exit fullscreen mode

isn't theoretical when you're processing millions of records.


25. Move Heavy Computation Away From Requests

Suppose you're generating:

PDF reports
AI embeddings
video thumbnails
large CSV exports
machine-learning predictions
image processing
financial reports
Enter fullscreen mode Exit fullscreen mode

Don't necessarily make the user wait for all of it.

Instead:

User
 |
 v
POST /reports
 |
 v
Django
 |
 +----> Create job
 |
 +----> Queue
          |
          v
        Worker
          |
          v
       Generate
          |
          v
       Storage
Enter fullscreen mode Exit fullscreen mode

Then the frontend can poll:

GET /reports/123
Enter fullscreen mode Exit fullscreen mode

or receive an event when the job is complete.

Now your web server stays responsive.


26. Profile Before and After

Suppose you've optimized your endpoint.

Don't say:

“It feels faster.”

Measure it.

Track:

p50
p95
p99
Enter fullscreen mode Exit fullscreen mode

Why?

Because averages can lie.

Imagine:

Request 1: 50ms
Request 2: 50ms
Request 3: 50ms
Request 4: 5 seconds
Enter fullscreen mode Exit fullscreen mode

The average hides the experience of the unlucky user.

Percentiles expose it.

For example:

p50 = 100ms
p95 = 500ms
p99 = 2.5s
Enter fullscreen mode Exit fullscreen mode

This tells you that the tail of your system is unhealthy.

At scale, tail latency matters enormously.


27. A Practical Optimization Stack

If I inherited a slow Django application, I wouldn't randomly rewrite it.

I'd work through the system in layers.

              PERFORMANCE STACK

       +---------------------------+
       |       CDN / Browser       |
       +---------------------------+
       |      HTTP Compression     |
       +---------------------------+
       |       Nginx / Proxy       |
       +---------------------------+
       |    Gunicorn / ASGI        |
       +---------------------------+
       |          Django           |
       +---------------------------+
       |     Cache / Redis         |
       +---------------------------+
       |       PostgreSQL          |
       +---------------------------+
       |     Disk / Infrastructure |
       +---------------------------+
Enter fullscreen mode Exit fullscreen mode

At each layer, ask:

HTTP

  • Are responses unnecessarily large?
  • Can content be cached?
  • Is compression enabled?

Django

  • Are views doing too much?
  • Are serializers causing queries?
  • Is middleware expensive?
  • Are synchronous operations blocking requests?

ORM

  • Are there N+1 queries?
  • Are querysets too large?
  • Are relationships prefetched?
  • Are only required fields selected?

PostgreSQL

  • Are indexes correct?
  • Are queries using them?
  • What does EXPLAIN ANALYZE say?
  • Are transactions too long?

Cache

  • What can be cached?
  • What is the cache hit rate?
  • Are cache keys correct?
  • How is invalidation handled?

Infrastructure

  • Are workers saturated?
  • Is memory exhausted?
  • Is the database connection limit being reached?
  • Is the network the bottleneck?

This is how you turn "Django is slow" into an engineering problem.


28. An Example: Turning a Slow Endpoint Into a Fast Endpoint

Imagine this endpoint:

def orders(request):
    orders = Order.objects.all()

    result = []

    for order in orders:
        result.append({
            "id": order.id,
            "customer": order.customer.name,
            "items": [
                {
                    "name": item.product.name,
                    "price": item.price,
                }
                for item in order.items.all()
            ]
        })

    return JsonResponse({
        "orders": result
    })
Enter fullscreen mode Exit fullscreen mode

This endpoint has several problems.

It loads every order.

It accesses customers lazily.

It accesses items lazily.

It accesses products lazily.

It returns everything.

Let's redesign it.

def orders(request):
    orders = (
        Order.objects
        .select_related("customer")
        .prefetch_related("items__product")
        .order_by("-created_at")[:50]
    )

    result = []

    for order in orders:
        result.append({
            "id": order.id,
            "customer": order.customer.name,
            "items": [
                {
                    "name": item.product.name,
                    "price": item.price,
                }
                for item in order.items.all()
            ]
        })

    return JsonResponse({
        "orders": result
    })
Enter fullscreen mode Exit fullscreen mode

Now the architecture is much better.

We have:

Before:

All orders
   |
   +--> Customer query
   +--> Customer query
   +--> Customer query
   |
   +--> Item query
   +--> Item query
   +--> Item query
   |
   +--> Product query
   +--> Product query
   +--> Product query
   |
   v
Huge response


After:

50 orders
   |
   +--> select_related(customer)
   |
   +--> prefetch_related(items__product)
   |
   v
Small response
Enter fullscreen mode Exit fullscreen mode

The important thing is that we didn't rewrite Django.

We simply stopped asking it to perform unnecessary work.


29. The 10x Formula

There is no universal switch called:

DJANGO_10X = True
Enter fullscreen mode Exit fullscreen mode

Real performance improvements usually come from multiplying improvements across layers.

For example:

2x from query optimization
×
2x from caching
×
1.5x from reducing serialization
×
1.5x from smaller responses
×
1.2x from infrastructure tuning

≈ 10.8x
Enter fullscreen mode Exit fullscreen mode

These improvements don't always multiply this cleanly in reality, but the concept is important.

You don't necessarily need one miraculous optimization.

You need several high-leverage optimizations.


30. The Most Important Performance Skill

The most valuable Django performance skill isn't memorizing:

select_related()
Enter fullscreen mode Exit fullscreen mode

or:

prefetch_related()
Enter fullscreen mode Exit fullscreen mode

or:

cache.get()
Enter fullscreen mode Exit fullscreen mode

It's learning to ask:

Where is the time going?

Suppose an endpoint takes:

1.2 seconds
Enter fullscreen mode Exit fullscreen mode

Break it down:

Network:          50ms
Django:           100ms
Database:         800ms
Serialization:    150ms
External API:     100ms
Enter fullscreen mode Exit fullscreen mode

Now you know what to attack.

The database is responsible for most of the latency.

So spend your time there.

After optimization:

Network:           50ms
Django:             80ms
Database:          120ms
Serialization:      50ms
External API:      100ms

Total:             400ms
Enter fullscreen mode Exit fullscreen mode

You just cut latency by roughly two-thirds.

That's engineering.


31. Don't Optimize Everything

This is where experienced engineers differ from beginners.

Beginners often want to optimize everything.

Experts optimize the bottleneck.

If a function takes:

0.2 ms
Enter fullscreen mode Exit fullscreen mode

and you make it:

0.1 ms
Enter fullscreen mode Exit fullscreen mode

you probably achieved nothing meaningful.

If a query takes:

900 ms
Enter fullscreen mode Exit fullscreen mode

and you make it:

50 ms
Enter fullscreen mode Exit fullscreen mode

you changed the system.

Optimization should follow impact.

A useful priority system is:

             High Impact
                  ^
                  |
       Database   |   External APIs
                  |
       Caching    |
                  |
       ORM        |
                  |
------------------+------------------> Effort
                  |
       Python     |
       micro-opt  |
                  |
             Low Impact
Enter fullscreen mode Exit fullscreen mode

Attack the upper-left region first.


32. A Performance Checklist

When optimizing Django, I mentally run through something like this:

[ ] Measure endpoint latency
[ ] Measure p50/p95/p99
[ ] Count database queries
[ ] Find N+1 queries
[ ] Inspect slow SQL
[ ] Run EXPLAIN ANALYZE
[ ] Add appropriate indexes
[ ] Use select_related()
[ ] Use prefetch_related()
[ ] Select only required fields
[ ] Use exists() for existence checks
[ ] Use count() when counting
[ ] Aggregate in the database
[ ] Paginate large datasets
[ ] Batch writes
[ ] Cache expensive reads
[ ] Reduce response size
[ ] Compress responses
[ ] Serve static assets efficiently
[ ] Move long jobs to workers
[ ] Review external API calls
[ ] Review database connections
[ ] Tune worker counts
[ ] Monitor CPU and memory
[ ] Measure again
Enter fullscreen mode Exit fullscreen mode

The last item is the most important.

Measure again.


33. The Architecture of a Fast Django System

A mature Django application doesn't look like:

Browser
   |
   v
Django
   |
   v
Database
Enter fullscreen mode Exit fullscreen mode

It starts looking more like:

                         INTERNET
                            |
                            v
                           CDN
                            |
                            v
                         NGINX
                            |
                    +-------+-------+
                    |               |
                    v               v
                 Static          Django
                                   |
                    +--------------+--------------+
                    |              |              |
                    v              v              v
                  Redis        PostgreSQL       Queue
                                                   |
                                                   v
                                                 Workers
                                                   |
                                                   v
                                           External Services
Enter fullscreen mode Exit fullscreen mode

The architecture works because each component has a job.

Django handles application logic.

PostgreSQL handles relational data.

Redis handles fast temporary state and caching.

Workers handle long-running tasks.

CDNs handle globally distributed static content.

Nginx handles reverse-proxy concerns.

The result isn't merely "faster Django."

It's less work for Django.

And that's the secret.


34. Django Isn't Usually the Problem

This is probably the most controversial thing I'll say:

Django is often blamed for performance problems that Django didn't create.

The ORM didn't decide to create 1,001 queries.

Your application did.

Django didn't decide to return 50 MB of JSON.

Your API did.

Django didn't decide to execute an external API call synchronously.

Your architecture did.

Django didn't forget your database index.

Your schema did.

Django didn't decide to process 500,000 objects in Python.

Your code did.

Frameworks give you tools.

They don't automatically give you good architecture.

And this is why the difference between a slow Django application and a fast Django application can be enormous even when both use exactly the same framework.


35. The Real Secret to 10x Performance

Making Django 10x faster isn't about discovering a secret Django setting.

It is about changing the amount of work your system performs.

Think about the progression:

Bad system:

Request
  |
  v
Django
  |
  +--> Huge query
  |
  +--> N+1 queries
  |
  +--> Expensive computation
  |
  +--> External API
  |
  +--> Giant serialization
  |
  +--> Giant response
  |
  v
Client
Enter fullscreen mode Exit fullscreen mode

Now compare that with:

Optimized system:

Request
  |
  v
Cache ---- HIT ----> Response
  |
 MISS
  |
  v
Django
  |
  +--> Optimized query
  |
  +--> Indexed database
  |
  +--> Small dataset
  |
  +--> Efficient serialization
  |
  +--> Background jobs
  |
  v
Compressed response
Enter fullscreen mode Exit fullscreen mode

The second system isn't necessarily running Python 10 times faster.

It's doing dramatically less unnecessary work.

That's a much more powerful optimization.


Conclusion: Make Django Boringly Fast

I like fast systems.

But I like predictable systems even more.

A backend that randomly takes 50 ms sometimes and 8 seconds at other times isn't truly fast.

A good production system should have:

Predictable latency
+
Efficient queries
+
Controlled memory
+
Healthy database connections
+
Effective caching
+
Small responses
+
Asynchronous background work
+
Good observability
Enter fullscreen mode Exit fullscreen mode

And Django can absolutely be part of that architecture.

The path to making Django 10x faster is usually not:

Rewrite everything.

It's:

Measure everything.

Then:

Find the bottleneck.

Then:

Remove unnecessary work.

Then:

Measure again.

Find the N+1 query.

Remove it.

Find the missing index.

Add it.

Find the 5 MB response.

Shrink it.

Find the repeated calculation.

Cache it.

Find the 30-second operation.

Move it to a worker.

Find the unnecessary database call.

Delete it.

Find the endpoint waiting on three APIs sequentially.

Make the architecture concurrent or asynchronous where appropriate.

Then measure again.

That's performance engineering.

And once you start seeing applications this way, something interesting happens.

You stop asking:

“How do I make Django faster?”

You start asking the much more powerful question:

“Why is the computer doing this work at all?”

That question is where the real 10x improvements begin.

Top comments (0)