Skip to content
Sudip KC writing notes in a journal at a desk
SK.
← All articles

Development · · 6 min read

Optimizing Slow Django APIs

  • Django
  • PostgreSQL
  • Performance
  • Tutorial

When a Django endpoint is slow, the problem is almost never Python. It is usually the database being asked the same question two hundred times, one row at a time, because a serializer looked innocent.

I build most of my backends with Django and Django REST Framework on PostgreSQL. Django is not slow. But it makes it very easy to write code that is slow without noticing, especially once the data stops being five test rows and starts being a real restaurant with months of orders.

This is the order I work through when an endpoint drags. Measure first, then fix the database access, then reduce the payload, and only then reach for caching.

Step 1: Measure before touching anything

Guessing wastes time. The first thing I want is the number of queries and how long they take.

  • Django Debug Toolbar for browsable endpoints during development. The SQL panel shows every query, duplicates included.
  • Query logging in development settings so I can see the SQL in the terminal while hitting the endpoint from the frontend.
  • `assertNumQueries` in tests, which turns a fixed problem into one that cannot quietly come back.

Here is the logging setup I drop into a local settings file:

LOGGING = {
    "version": 1,
    "handlers": {"console": {"class": "logging.StreamHandler"}},
    "loggers": {
        "django.db.backends": {
            "handlers": ["console"],
            "level": "DEBUG",
        },
    },
}

If an order list endpoint for twenty orders fires a hundred-plus queries, I already know where this is going.

Step 2: Kill the N+1 queries

The N+1 problem is the classic. You fetch a list of orders in one query, then the serializer touches order.table and order.waiter and order.items for each order, firing new queries every time.

Here is a typical serializer that looks fine and is not:

class OrderSerializer(serializers.ModelSerializer):
    table_name = serializers.CharField(source="table.name")
    waiter_name = serializers.CharField(source="waiter.get_full_name")
    items = OrderItemSerializer(many=True)

    class Meta:
        model = Order
        fields = ["id", "status", "table_name", "waiter_name", "items", "created_at"]

Every source="table.name" is a foreign key lookup. Every nested items is another query, and if OrderItemSerializer reads item.menu_item.name, that is one more query per item. The fix lives in the view's queryset, not the serializer:

from django.db.models import Prefetch

class OrderViewSet(viewsets.ReadOnlyModelViewSet):
    serializer_class = OrderSerializer

    def get_queryset(self):
        items = OrderItem.objects.select_related("menu_item")
        return (
            Order.objects
            .filter(restaurant=self.request.user.restaurant)
            .select_related("table", "waiter")
            .prefetch_related(Prefetch("items", queryset=items))
            .order_by("-created_at")
        )

The rule of thumb:

  • `select_related` for foreign keys and one-to-one fields. It uses a SQL join, one query.
  • `prefetch_related` for reverse foreign keys and many-to-many. It runs one extra query per relation and stitches the results in Python.
  • `Prefetch` objects when the prefetched queryset needs its own select_related or filtering.

That change alone usually takes an endpoint from a hundred-something queries to three or four. The Django database optimization docs cover this well and are worth rereading once a year.

Watch out for SerializerMethodField

SerializerMethodField is where N+1 hides best. A method like get_item_count that calls obj.items.count() runs a query per row, even if you prefetched items, because .count() on a related manager goes to the database. Either use len(obj.items.all()) on the prefetched data or, better, compute it in the queryset.

Step 3: Let the database do the math

Counting, summing, and checking existence in Python means loading rows you do not need. annotate and aggregate push that work into PostgreSQL, which is very good at it.

from django.db.models import Count, Sum, F

orders = (
    Order.objects
    .filter(restaurant=restaurant)
    .annotate(
        item_count=Count("items"),
        total=Sum(F("items__quantity") * F("items__unit_price")),
    )
)

Now the serializer reads item_count and total as plain fields. No per-row queries, no loading every item into memory.

Two smaller habits in the same spirit:

  • Use .exists() instead of if queryset: or len(queryset) > 0 when you only need to know whether something is there.
  • Use .only() or .values() when an endpoint needs three columns from a table with thirty. This matters most on tables with large text or JSON fields.
Every time you loop over a queryset to calculate something, ask whether PostgreSQL could have handed you the answer directly.

Step 4: Add the right indexes

Once the query count is sane, I look at individual slow queries. In PostgreSQL, EXPLAIN ANALYZE tells you whether a query is scanning the whole table.

The common culprits are filters and orderings on columns that are not indexed. An order list filtered by restaurant and status, sorted by newest, wants a composite index that matches that access pattern:

class Order(models.Model):
    restaurant = models.ForeignKey(Restaurant, on_delete=models.CASCADE)
    status = models.CharField(max_length=20)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        indexes = [
            models.Index(fields=["restaurant", "status", "-created_at"]),
        ]

Foreign keys already get an index in Django, so do not add duplicates. And do not index everything: every index slows down writes and takes space. Index what your real queries filter and sort by, and confirm with EXPLAIN ANALYZE that the index is actually used.

Step 5: Paginate and trim the payload

Some endpoints are slow simply because they return too much. An orders endpoint that returns every order since the restaurant opened will eventually fall over no matter how well it is optimized.

  • Always paginate list endpoints. For feeds and logs that keep growing, cursor pagination in DRF stays fast on deep pages where offset pagination gets slower.
  • Split list and detail serializers. The list view rarely needs nested items, notes, and audit history. Send a light summary, then load the detail on demand.
  • Do not nest blindly. Three levels of nested serializers is usually a sign the endpoint is trying to be the whole app.

Smaller responses also matter a lot on mobile data in Nepal, where a heavy JSON payload over an unstable connection is felt immediately. I went deeper on that in Designing Apps for Slow Internet Connections.

Step 6: Cache what is expensive and rarely changes

Caching comes last on purpose. If you cache a badly written endpoint, you have hidden the problem until the cache expires at the worst possible moment.

Good candidates are things that are read often and change rarely: a restaurant's menu, category lists, settings, dashboard aggregates that can be a few minutes old. I prefer caching at the data level with Django's cache framework, backed by something like Redis in production, and invalidating on save, rather than caching whole HTTP responses that depend on the user's role.

from django.core.cache import cache

def get_menu(restaurant_id):
    key = f"menu:{restaurant_id}"
    menu = cache.get(key)
    if menu is None:
        menu = build_menu_payload(restaurant_id)
        cache.set(key, menu, timeout=600)
    return menu

Then a signal or service function deletes menu:{id} whenever a menu item changes. Simple, explicit, easy to reason about.

Step 7: Lock it in with tests

Optimizations are fragile. Someone adds a field to a serializer six months later and the N+1 comes back. A query-count test stops that:

def test_order_list_query_count(self):
    make_orders(restaurant=self.restaurant, count=20)
    with self.assertNumQueries(4):
        self.client.get("/api/orders/")

If the count changes, the test fails, and the person who changed the serializer has to look at why. That has caught more regressions for me than any monitoring dashboard.

The short version

When a Django API is slow, measure the queries, fix N+1 with select_related and prefetch_related, push calculations into the database with annotate, index what you filter and sort by, paginate and slim the payload, and only then cache. Then write a test that pins the query count. None of it is exotic, and that is the point: most slow endpoints are a handful of ordinary fixes away from fast. If you want the bigger picture of how I lay out these projects, see How I Structure React + Django Projects.