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.
- Count queries per endpoint (django-debug-toolbar,
django-silk, orassertNumQueriesin tests). - Override
get_querysetwithselect_relatedfor foreign keys andprefetch_related(withPrefetchobjects) for reverse and many-to-many relations used by serializers. - Annotate aggregates in SQL (
annotate(item_count=Count("items"))) instead ofSerializerMethodFieldqueries. - Paginate every list endpoint and cap
page_size. - Serialize only needed fields; use separate list and detail serializers.
- Cache expensive, shared responses and use
.only()orvalues()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.
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.
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=...); filteringobj.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
- Deep dive on query patterns with Fix Django N+1 Queries.
- Paginate huge tables efficiently with Fast Pagination for Large Django Tables.
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.