Speed Up Django REST Framework APIs

Make Django REST Framework APIs faster in production: eliminate N+1 queries with select_related and prefetch_related, slim serializers, paginate everything, cache expensive endpoints, use values() for read-heavy lists, and measure with profiling.

Problem statement

A DRF endpoint that returns 50 orders with their customers and line items takes two seconds and runs 300 queries. Nested serializers, SerializerMethodFields, and model properties make it easy to trigger a query per object, and default settings may return unbounded lists. DRF itself is rarely the bottleneck; how the queryset is built and how much work serializers do per object is. A few systematic changes usually make such endpoints ten times faster.

Where DRF time goesQuerysetN+1 relationsSerializerper-object workResponse sizeunpaginated listsFix in orderqueries firstMost slow DRF endpoints are query problems, then serializer work, then payload size.
  1. Count queries per endpoint (django-debug-toolbar, django-silk, or assertNumQueries in tests).
  2. Override get_queryset with select_related for foreign keys and prefetch_related (with Prefetch objects) for reverse and many-to-many relations used by serializers.
  3. Annotate aggregates in SQL (annotate(item_count=Count("items"))) instead of SerializerMethodField queries.
  4. Paginate every list endpoint and cap page_size.
  5. Serialize only needed fields; use separate list and detail serializers.
  6. Cache expensive, shared responses and use .only() or values() for large read-only lists.

Step-by-step solution

1. Measure queries

from django.test.utils import CaptureQueriesContext
from django.db import connection

def test_orders_list_queries(api_client, orders):
    with CaptureQueriesContext(connection) as ctx:
        api_client.get("/api/orders/")
    assert len(ctx.captured_queries) <= 5

A query count that grows with the number of results is the N+1 signature. Add tests like this so regressions are caught in CI.

2. Shape the queryset for the serializer

from django.db.models import Count, Prefetch

class OrderViewSet(viewsets.ReadOnlyModelViewSet):
    serializer_class = OrderSerializer

    def get_queryset(self):
        return (
            Order.objects.filter(customer__account=self.request.user.account)
            .select_related("customer", "shipping_address")
            .prefetch_related(Prefetch("items", queryset=OrderItem.objects.select_related("product")))
            .annotate(item_count=Count("items"))
            .order_by("-created_at")
        )

Every relation the serializer touches must be loaded here: select_related joins single-valued relations into the main query, prefetch_related runs one extra query per relation for many-valued ones.

queries for 50 orders (illustrative)naive queryset301+ select_related201+ prefetch items4+ annotate counts30150300The count should stay flat as page size growsdjango-deployment.com
Illustrative numbers: each fix removes one per-object query pattern.

3. Move per-object work into SQL

class OrderSerializer(serializers.ModelSerializer):
    item_count = serializers.IntegerField(read_only=True)   # from annotate, not a query per order
    customer_name = serializers.CharField(source="customer.name", read_only=True)

    class Meta:
        model = Order
        fields = ["id", "created_at", "status", "total", "item_count", "customer_name"]

A SerializerMethodField that calls obj.items.count() runs one query per order; an annotation computes it once.

4. Paginate and cap

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

Allow clients to choose a page size only with max_page_size. For very large tables, use CursorPagination, which avoids slow OFFSET and COUNT(*) queries.

5. Slim list responses

Use a compact serializer for lists and a full one for detail views:

def get_serializer_class(self):
    return OrderListSerializer if self.action == "list" else OrderDetailSerializer

For read-only lists with thousands of rows (exports, dropdown data), skip model instances entirely with .values("id", "name") and a plain serializer, or return the data directly.

6. Cache shared responses

Endpoints that return the same data for everyone (catalogue, configuration, public stats) can be cached with cache_page on the view or low-level caching around the expensive part. Vary on query parameters and language as needed; never cache per-user data without the user in the key.

DRF performance basicsQuery count tested per endpointflat as results growselect_related and Prefetch in get_querysetmatch serializer fieldsAggregates via annotateno per-object count()Pagination with max_page_sizecursor for huge tablesList vs detail serializersonly needed fieldsFix queries before tuning servers or switching frameworksdjango-deployment.com
A query-count test per endpoint prevents slow regressions from returning.

Verification check: the endpoint's query count no longer grows with page size, p95 latency drops in your APM or logs, and the query-count tests pass in CI.

Explanation

DRF serializers walk model attributes for each object. Each access to an unloaded relation or a method that queries the database triggers SQL, so serializing 50 objects can mean hundreds of queries, each with network round-trip cost. Loading relations in bulk with select_related and prefetch_related, and computing aggregates in the database, collapses those into a handful of queries. After that, serializer overhead and payload size become relevant, which slimmer serializers and pagination address.

Edge cases / notes

  • Prefetch with filtering must use Prefetch(queryset=...); filtering obj.items.filter(...) in the serializer bypasses the prefetch cache.
  • Permissions that check related objects per item also cause N+1; batch them.
  • Serializer speed for very large payloads can be improved with lighter libraries, but only after queries are fixed.
  • Database indexes for the filter and ordering columns matter as much as query count.

Next steps

FAQ

Why is my DRF API slow?

Usually N+1 queries from nested serializers or method fields. Count queries per request first.

How do I fix N+1 queries in DRF?

Add select_related and prefetch_related in get_queryset for every relation the serializer uses.

Should every DRF list endpoint be paginated?

Yes, with a maximum page size, to keep responses and queries bounded.

Does caching help DRF APIs?

For responses shared across users, yes. For personal data, cache only with the user in the key.

Keep going