Paramclasses Academy - Blog

Tutorials Details

←
Post 28 of 29
→

Django REST Framework Performance Optimization and Caching – Query Optimization, Database Indexing, API Caching, and Faster Responses

Django REST Framework Performance Optimization and Caching – Query Optimization, Database Indexing, API Caching, and Faster Responses

October 21, 2026

Django REST Framework Performance Optimization and Caching: Query Optimization, Database Indexing, API Caching, and Faster Responses

Introduction

As a Django REST Framework application grows, its APIs may need to handle more users, larger datasets, and an increasing number of requests. Poorly optimized database queries, repeated calculations, and unnecessary database access can make API responses slow.

Performance optimization improves the speed and efficiency of an application while maintaining correct behavior and data security.

Caching stores reusable results temporarily so that the application does not need to perform the same expensive operations repeatedly. Django also provides tools such as select_related(), prefetch_related(), and database indexes to improve database performance.

In this module, you will learn how to identify common API performance problems, optimize ORM queries, use caching, and measure improvements.

What You Will Learn

  • Common causes of slow Django REST APIs.

  • How to optimize queries using select_related().

  • How to use prefetch_related() for related collections.

  • How to identify and reduce N+1 query problems.

  • How database indexes improve query performance.

  • How to configure Django's cache framework.

  • How to cache API responses and reusable data.

  • How to use pagination for large datasets.

  • How to measure API performance and verify improvements.

1. Understanding API Performance Problems

API performance depends on several factors, including database queries, serialization, network latency, application logic, and server resources.

Common performance problems include:

  • Executing too many database queries.

  • Retrieving unnecessary database columns or records.

  • Repeating expensive calculations.

  • Returning thousands of records in one response.

  • Running similar queries for every request.

  • Using inefficient filtering and sorting operations.

For example, an API that returns 10 products might work quickly. However, an API that retrieves 100,000 products without pagination may consume significant memory and take much longer to respond.

The first step toward optimization is to measure the application and identify the actual bottleneck instead of guessing.

2. Understanding the N+1 Query Problem

The N+1 query problem occurs when an application retrieves a collection of records and then performs an additional database query for each record to retrieve related information.

Consider these models:

models.py

from django.db import models


class Category(models.Model):
    name = models.CharField(max_length=100)


class Product(models.Model):
    name = models.CharField(max_length=100)
    price = models.DecimalField(
        max_digits=10,
        decimal_places=2
    )
    category = models.ForeignKey(
        Category,
        on_delete=models.CASCADE,
        related_name="products"
    )

Suppose the API retrieves products and accesses their category names:

products = Product.objects.all()

for product in products:
    print(product.name, product.category.name)

The database may execute one query to retrieve the products and additional queries to retrieve their categories. With many products, this can become inefficient.

The exact number of queries depends on the relationships, cached objects, and how the queryset is evaluated.

3. Optimizing Queries with select_related()

Django's select_related() retrieves related objects in the same SQL query by using a SQL JOIN. It is designed for single-valued relationships, such as ForeignKey and OneToOneField.

Optimized example:

products = Product.objects.select_related(
    "category"
)

for product in products:
    print(product.name, product.category.name)

Instead of issuing a separate query for each category, Django can retrieve the product and its category together.

When Should You Use select_related()?

Use it when your API needs related information from:

  • A ForeignKey relationship.

  • A OneToOneField relationship.

For example, a product API may need the category name, or an order API may need customer details.

Avoid joining large numbers of unnecessary relationships without measuring the impact. A larger SQL query is not automatically faster in every situation.

4. Optimizing Related Collections with prefetch_related()

prefetch_related() retrieves related objects using separate queries and joins the results in Python. It is especially useful for reverse relationships and many-to-many relationships.

Suppose each category has multiple products:

categories = Category.objects.prefetch_related(
    "products"
)

for category in categories:
    for product in category.products.all():
        print(category.name, product.name)

Django can fetch the categories and their related products in a small number of queries instead of querying the products separately for each category.

Difference Between select_related() and prefetch_related()

Feature select_related() prefetch_related()
Main technique SQL JOIN Separate queries combined in Python
Best suited for ForeignKey and OneToOneField Reverse and many-to-many relationships
Query behavior Retrieves related data in the same SQL query Retrieves related data using additional queries
Typical purpose Fetch one related object Fetch collections of related objects

Both techniques can improve API performance when used appropriately.

5. Optimizing a Django REST Framework ViewSet

Consider a product serializer that returns category details.

serializers.py

from rest_framework import serializers
from .models import Product


class ProductSerializer(serializers.ModelSerializer):
    category_name = serializers.CharField(
        source="category.name",
        read_only=True
    )

    class Meta:
        model = Product
        fields = [
            "id",
            "name",
            "price",
            "category",
            "category_name",
        ]

A basic viewset might look like this:

from rest_framework.viewsets import ModelViewSet
from .models import Product
from .serializers import ProductSerializer


class ProductViewSet(ModelViewSet):
    queryset = Product.objects.all()
    serializer_class = ProductSerializer

Because the serializer accesses category.name, the viewset can benefit from select_related().

Optimized views.py

class ProductViewSet(ModelViewSet):
    queryset = Product.objects.select_related(
        "category"
    )
    serializer_class = ProductSerializer

This reduces unnecessary database queries when the serializer accesses each product's category.

For serializers that access reverse or many-to-many relationships, consider using prefetch_related() as appropriate.

6. Using only() and defer() Carefully

Django provides only() and defer() to control which model fields are loaded immediately.

Using only()

products = Product.objects.only(
    "id",
    "name",
    "price"
)

This loads the selected fields initially. Accessing a deferred field later can trigger another database query.

Using defer()

products = Product.objects.defer("description")

This delays loading the specified field until it is accessed.

These methods can help when large fields are genuinely unnecessary, but they can also introduce extra queries if deferred fields are accessed later. Use them only after checking the serializer and view logic.

7. Understanding Database Indexes

A database index helps the database locate records more efficiently for supported queries. Without a suitable index, some queries may need to examine many rows.

For example, if your API frequently filters products by name, an index may help:

class Product(models.Model):
    name = models.CharField(
        max_length=100,
        db_index=True
    )
    price = models.DecimalField(
        max_digits=10,
        decimal_places=2
    )

After changing the model, generate and apply migrations:

python manage.py makemigrations
python manage.py migrate

When Are Indexes Useful?

Indexes can help with frequent filtering, joins, and ordering operations when the database query planner can use them.

However, indexes also consume storage and add overhead to inserts, updates, and deletes.

Do not add indexes to every field automatically. Check real query patterns and use database query plans to determine whether an index is beneficial.

8. Understanding Django Caching

Caching stores reusable data so that it can be retrieved without repeating the original expensive operation.

For example, a product category list may be requested frequently but change infrequently. Caching it can reduce repeated database work.

Django supports multiple cache backends, including:

  • Local memory cache.

  • File-based cache.

  • Database cache.

  • Memcached.

  • Redis through a compatible backend.

For a single-process development example, local memory caching is convenient. For production deployments with multiple application workers or servers, a shared cache backend is often more suitable.

9. Configuring a Cache Backend

A simple local memory cache can be configured in settings.py:

CACHES = {
    "default": {
        "BACKEND":
            "django.core.cache.backends.locmem.LocMemCache",
        "LOCATION": "product-api-cache",
        "TIMEOUT": 300,
    }
}

The TIMEOUT value is measured in seconds. In this example, cached entries expire after five minutes unless another timeout is specified.

Important: Local memory caching is generally local to a process and is not automatically shared across multiple workers or servers. A production system may need Redis or Memcached for shared caching.

10. Caching Data with Django's Cache API

Django provides cache.get() and cache.set() to retrieve and store cached values.

Example:

from django.core.cache import cache
from .models import Category


def get_category_names():
    cache_key = "category_names"

    category_names = cache.get(cache_key)

    if category_names is None:
        category_names = list(
            Category.objects.values_list(
                "name",
                flat=True
            )
        )

        cache.set(
            cache_key,
            category_names,
            timeout=300
        )

    return category_names

How This Works

  1. The function looks for a cached value.

  2. If the value exists, it returns the cached result.

  3. If no value exists, it queries the database.

  4. The result is stored in the cache for 300 seconds.

  5. Subsequent calls can reuse the cached result until it expires or is invalidated.

Using is None allows an empty list to be treated as a valid cached result rather than an automatic cache miss.

11. Caching an API Response

Django REST Framework can be combined with Django's cache framework to cache suitable API responses.

For example, Django's cache_page decorator can cache the output of a function-based view:

from django.views.decorators.cache import cache_page
from django.utils.decorators import method_decorator
from rest_framework.decorators import api_view
from rest_framework.response import Response

from .models import Category


@api_view(["GET"])
@cache_page(60 * 5)
def category_list(request):
    categories = Category.objects.values(
        "id",
        "name"
    )

    return Response(list(categories))

This example caches a public category-list response for five minutes.

Important Caching Considerations

Before caching an API response, determine whether it depends on:

  • The authenticated user.

  • User permissions.

  • Request headers.

  • Query parameters.

  • Language or other request-specific settings.

  • Frequently changing data.

Incorrectly caching personalized or permission-sensitive responses can expose one user's data to another user. Use a suitable cache strategy and verify how cache keys vary for different requests.

Do not apply public response caching indiscriminately to endpoints that return private user information.

12. Cache Invalidation

Cache invalidation means removing or updating cached data when the original data changes.

For example, when a category is created, updated, or deleted, the cached category list may become outdated.

A simple invalidation operation is:

from django.core.cache import cache

cache.delete("category_names")

You can call this after a successful operation that changes category data.

However, cache invalidation becomes more complicated when multiple endpoints, related models, and cached query variants are involved. Larger projects may use versioned cache keys, explicit invalidation policies, or short expiration periods.

Best practice: Decide how cached data will be refreshed before adding caching to an API.

13. Using Pagination for Large Datasets

Returning every record in one response can increase memory usage, database work, and network transfer time.

Django REST Framework supports pagination classes such as PageNumberPagination.

Example:

REST_FRAMEWORK = {
    "DEFAULT_PAGINATION_CLASS":
        "rest_framework.pagination.PageNumberPagination",
    "PAGE_SIZE": 20,
}

If your project already defines REST_FRAMEWORK settings, merge these entries with the existing configuration.

Clients can request a specific page using a query parameter such as:

/api/products/?page=2

Pagination reduces the amount of data returned per request and is especially useful for list endpoints containing many records.

The best page size depends on the response size, query cost, and client requirements.

14. Measuring Query Performance

Optimization should be based on measurement rather than assumptions.

Django provides tools for inspecting SQL queries and execution plans.

For example:

queryset = Product.objects.select_related(
    "category"
)

print(queryset.explain())

The explain() method asks the database for a query execution plan. The exact information returned depends on the database backend.

You can also inspect the SQL generated by a queryset during development:

print(queryset.query)

For more complete analysis, use database monitoring, Django Debug Toolbar in development, and appropriate application performance monitoring tools.

Be careful when sharing query logs because they can sometimes contain sensitive information.

15. A Practical Performance Optimization Workflow

Follow this process when improving a slow API:

  1. Measure the current response time.

  2. Identify the slow endpoint.

  3. Inspect its database queries.

  4. Look for repeated queries and N+1 problems.

  5. Apply select_related() or prefetch_related() where appropriate.

  6. Check whether filtering or ordering needs an index.

  7. Add pagination to large list endpoints.

  8. Cache frequently requested data when appropriate.

  9. Verify cache invalidation and permission behavior.

  10. Run tests and measure performance again.

This workflow helps ensure that performance changes improve the application without introducing incorrect results or security issues.

Frequently Asked Questions (FAQs)

Q1. What is API performance optimization?

API performance optimization improves response times and resource efficiency by reducing unnecessary database queries, limiting data retrieval, using caching, and improving application logic.

Q2. What is the N+1 query problem?

It occurs when an application retrieves a collection of records and then issues additional database queries for related objects repeatedly.

Q3. What is the difference between select_related() and prefetch_related()?

select_related() uses SQL joins for single-valued relationships. prefetch_related() retrieves related data using additional queries and combines the results in Python.

Q4. What is caching?

Caching stores reusable results temporarily so that the application can avoid repeating expensive operations.

Q5. What is cache invalidation?

Cache invalidation removes or refreshes cached information when the underlying data changes.

Q6. What is a database index?

A database index is a structure that can help a database find matching records more efficiently for supported queries.

Q7. Does caching always improve performance?

No. Caching introduces storage and invalidation overhead. It is most useful for expensive results that are requested frequently and can safely be reused.

Q8. Why is pagination important?

Pagination limits the number of records returned in a single response, helping reduce data transfer and resource usage for large datasets.

Interview Questions and Answers

1. What is the N+1 query problem in Django?

It is a performance problem in which retrieving related objects causes additional queries for individual records, increasing total database work.

2. When should you use select_related()?

Use it when fetching related objects through ForeignKey or OneToOneField relationships.

3. When should you use prefetch_related()?

Use it for reverse relationships and many-to-many relationships where related collections need to be retrieved efficiently.

4. What is the purpose of a database index?

An index can help the database locate matching records faster for queries that can use it, though it adds storage and write overhead.

5. What is the difference between cache expiration and cache invalidation?

Expiration removes or makes a cached entry unavailable after a configured period. Invalidation deliberately removes or refreshes cached data when it is no longer valid.

6. Why can caching personalized API responses be dangerous?

If cache keys do not properly distinguish users or permissions, one user's response could be served to another user.

7. How can you inspect a Django queryset's execution plan?

Use the queryset's explain() method to inspect the query plan provided by the database backend.

8. How does pagination improve API performance?

It limits the number of records returned per request, reducing response size and often lowering memory and processing requirements.

9. Does adding an index guarantee a faster query?

No. The database query planner decides whether an index is useful. The result depends on the query, data distribution, and database implementation.

10. How do you verify a performance improvement?

Measure response times and query counts before and after the change, test correctness, and use representative workloads.

Practical Assignment

Build a Product Management API and improve its performance.

Requirements:

  1. Create Product and Category models.

  2. Build serializers that include related category information.

  3. Configure a product list endpoint using ModelViewSet.

  4. Use select_related() for category relationships.

  5. Add a suitable database index for a frequently queried field.

  6. Configure pagination.

  7. Set up a Django cache backend.

  8. Cache a suitable public category-list response.

  9. Invalidate the category cache after category changes.

  10. Test that personalized or protected responses are not incorrectly shared through caching.

  11. Inspect SQL queries and execution plans.

  12. Compare query counts and response times before and after optimization.

Expected Result: A Django REST Framework API with more efficient ORM queries, suitable pagination, a deliberate caching strategy, and measured performance improvements.

Conclusion

In this module, you learned how to improve Django REST Framework performance using ORM query optimization, database indexes, caching, pagination, and query-plan inspection.

The most effective optimization strategy is to measure first, address the actual bottleneck, and then verify that the improvement preserves correctness and security.

In Module 30, you will bring the course together with a Complete Django REST Framework Project – Building, Testing, Securing, and Preparing a Real-World API for Deployment.

←
Post 28 of 29
→