Solving N+1 Query Issues in Django: A Simple Fix That Made a Big Difference

A few months ago, I was asked to investigate a slow API. It was part of a reporting dashboard. The endpoint returned a list of reports, each with basic user info. On the surface, the logic was straightforward. But the response time was much slower than expected.

It wasn’t doing anything complex, just looping through reports and attaching related user data. But performance was bad, even with a relatively small number of records. I decided to check what was going on.

The Code

Here’s what the original code (simplified) looked like:

class ReportListView(APIView):

    def get(self, request):

        reports = Report.objects.all()

        data = []

        for report in reports:

            data.append({

                "id": report.id,

                "title": report.title,

                "user": {

                    "id": report.user.id,

                    "name": report.user.full_name,

                },

                # other fields

            })

        return Response(data)

There was nothing unusual here. It queried all reports, looped through them, and added user info into the response. But when I tested the endpoint with just 50-100 reports, it slowed down a lot.

Debugging with Django Debug Toolbar

To find out why it was slow, I used Django Debug Toolbar. It showed something obvious: the view was making one query to get all reports and then one additional query per report to fetch the related user.

So for 100 reports, there were 101 queries hitting the database.

That’s the classic N+1 query issue. Django was lazily loading report.user each time inside the loop. This approach doesn’t scale. The more records you fetch, the more queries you end up executing. That creates unnecessary load on the database and increases response time.

The Fix

The solution in Django is simple: use select_related() when you know you’ll need related data from a foreign key field.

Here’s the fixed version:

class ReportListView(APIView):

    def get(self, request):

        reports = Report.objects.select_related('user').all()

        data = []

        for report in reports:

            data.append({

                "id": report.id,

                "title": report.title,

                "user": {

                    "id": report.user.id,

                    "name": report.user.full_name,

                }

            })

        return Response(data)

By using select_related('user'), Django joins the user table in the same query. This means only one query is made to fetch everything. The query count dropped from 101 to 1.

This small change made a big difference. The response was almost instant after that.

When to Use 

select_related()

Use select_related() for foreign key and one-to-one fields when you’ll need related object data. It tells Django to join those tables in SQL and load everything at once. It avoids separate lookups per object.

In our case, each report had a user_id foreign key. Since we needed data from the User model, select_related() was the correct choice.

Handling Many-to-Many or Reverse Relations

If you’re working with many-to-many fields or reverse foreign keys (e.g., a user’s posts), use prefetch_related() instead.

Example:

posts = Post.objects.prefetch_related('tags').all()

for post in posts:

    tag_names = [tag.name for tag in post.tags.all()]

prefetch_related() does two queries: one for posts and one for tags. Then it matches them in Python. It’s not a SQL join, but still much better than one query per item.

Another example:

users = User.objects.prefetch_related('posts').all()

for user in users:

    post_titles = [post.title for post in user.posts.all()]

This approach prevents N+1 problems when accessing reverse or many-to-many relationships.

Key Takeaways

1. Watch for N+1 Query Patterns

If you’re looping over items and accessing related fields, check how many queries are being made. Use Django Debug Toolbar or log your queries to see what’s happening.

Even if the code looks simple, it might be hitting the database repeatedly in the background.

2. Use select_related() and prefetch_related() early

These two methods solve most N+1 problems. You don’t need complex optimizations. Just fetch what you need in advance, in fewer queries.

3. Check Query Counts in Development

Make it a habit to inspect query counts. If an endpoint feels slow, it probably is. N+1 issues are common and easy to miss during early development.

4. Query with Intent

Django’s ORM makes it easy to access related fields. But that convenience comes at a cost if you’re not careful. Fetch only what you need, and use tools Django provides to avoid performance hits.

Conclusion

This was one of the simplest fixes I’ve made that had a big impact. No major refactor. No caching. Just one line of code to use select_related().

Before the fix, the view made over 50 queries. After the fix, it made just one. That one change brought the response time down and solved the performance issue.

So if you ever notice an API slowing down, check the number of queries. The fix might be just one line away.