{"slug": "how-i-caught-and-fixed-an-n-1-query-in-my-django-rest-api", "title": "How I Caught and Fixed an N+1 Query in My Django REST API", "summary": "The article describes how the author identified and fixed an N+1 query problem in a Django REST API, where lazy ORM evaluation caused excessive database queries when serializing related fields like ForeignKey and ManyToMany relationships. The fix involved adding `select_related()` for ForeignKey relations and `prefetch_related()` for ManyToMany relations to the queryset, reducing the query count from 61 to just 3 for a 30-post list endpoint. The author emphasizes that developers should proactively check for N+1 queries during code review whenever nested serializers or related field accesses are present.", "body_md": "Every performant API eventually runs into the same silent killer: the N+1 query problem. It doesn't crash your app. It doesn't throw errors. It just quietly makes every list endpoint slower as your data grows — and it's almost invisible until Sentry flags it in production.\n\nToday, Sentry caught one on my `/api/blog-posts/`\n\nendpoint. Here's exactly what happened and how I fixed it in three lines of code.\n\n## What Is an N+1 Query?\n\nAn N+1 query happens when your code fetches a list of N records, then fires an additional query *per record* to fetch related data — totalling `1 + N`\n\ndatabase hits instead of a flat 2 or 3.\n\nIn Django, this usually happens silently because the ORM is lazy by default. Accessing a related object on a model instance that wasn't eagerly loaded triggers a fresh `SELECT`\n\non the spot. With 30 blog posts, that's 30 silent queries you never wrote.\n\n## The Offending Code\n\nThe `BlogPostViewSet`\n\nlooked clean on the surface:\n\n```\nclass BlogPostViewSet(viewsets.ReadOnlyModelViewSet):\n    queryset = BlogPost.objects.all()\n    serializer_class = BlogPostSerializer\n    lookup_field = \"uid\"\n```\n\nAnd the serializer:\n\n```\nclass BlogPostSerializer(serializers.ModelSerializer):\n    tags = BlogTagSerializer(many=True, read_only=True)\n    series = BlogSeriesSerializer(read_only=True)\n    ...\n```\n\nSpot the problem? `BlogPost`\n\nhas two relations:\n\n-\n`series`\n\n— a`ForeignKey`\n\nto`BlogSeries`\n\n-\n`tags`\n\n— a`ManyToManyField`\n\nto`BlogTag`\n\nWhen DRF serializes a list of 30 posts, it accesses `post.series`\n\nand `post.tags`\n\non each one. Without eager loading, Django fires two extra queries per post — one to fetch the series, one to fetch the tags. That's **1 + 60 queries** for a 30-post list.\n\nThe `featured`\n\naction had the same issue:\n\n``` python\n@action(detail=False, methods=[\"get\"])\ndef featured(self, request):\n    queryset = BlogPost.objects.filter(date_published__isnull=False).order_by(\n        \"-date_published\",\n    )[:3]\n```\n\nA fresh `BlogPost.objects`\n\ncall with no eager loading.\n\n## The Fix\n\nDjango gives me two tools for this:\n\n-\n— for`select_related()`\n\n`ForeignKey`\n\nand`OneToOne`\n\nrelations. Issues a SQL`JOIN`\n\nand fetches everything in a single query. -\n— for`prefetch_related()`\n\n`ManyToMany`\n\nand reverse FK relations. Issues a second query and caches the results in Python.\n\nThe fix:\n\n```\nclass BlogPostViewSet(viewsets.ReadOnlyModelViewSet):\n    queryset = BlogPost.objects.select_related(\"series\").prefetch_related(\"tags\")\n    serializer_class = BlogPostSerializer\n    lookup_field = \"uid\"\n\n    @action(detail=False, methods=[\"get\"])\n    def featured(self, request):\n        queryset = (\n            BlogPost.objects.select_related(\"series\")\n            .prefetch_related(\"tags\")\n            .filter(date_published__isnull=False)\n            .order_by(\"-date_published\")[:3]\n        )\n        serializer = self.get_serializer(queryset, many=True)\n        return Response(serializer.data)\n```\n\nWith 30 posts, the list endpoint now costs **3 queries** regardless of dataset size:\n\n`SELECT * FROM core_blogpost ...`\n\n`SELECT * FROM core_blogseries WHERE id IN (...)`\n\n`SELECT * FROM core_blogtag INNER JOIN core_blogpost_tags WHERE blogpost_id IN (...)`\n\n## The Bonus Fix\n\nWhile auditing the blog endpoint, I spotted the same pattern in `TestimonialViewSet`\n\n. Its serializer accesses `project.title`\n\nand `project.slug`\n\n, but the queryset had no `select_related`\n\n:\n\n```\n# Before\nqueryset = Testimonial.objects.all()\n\n# After\nqueryset = Testimonial.objects.select_related(\"project\")\n```\n\nOne extra line, one less N+1.\n\n## How to Spot This in Your Own Code\n\nThe pattern is always the same — look for any ViewSet or view where:\n\n- The queryset has no\n`select_related`\n\nor`prefetch_related`\n\n- The serializer accesses a related field (\n`source=\"relation.field\"`\n\n, nested serializers,`SerializerMethodField`\n\nthat touches`obj.relation`\n\n)\n\nTools that help catch this before Sentry does:\n\n-\n— shows query counts per request in the browser[django-debug-toolbar](https://github.com/jazzband/django-debug-toolbar) -\n— raises exceptions in tests when N+1 queries are detected[nplusone](https://github.com/jmcarp/nplusone) -\n**Sentry Performance**— catches it in production with query traces\n\nThe best time to catch an N+1 is during code review. Any time you write a nested serializer, ask: *does the queryset for this view eagerly load this relation?*\n\n## Takeaway\n\nThe Django ORM's lazy evaluation is a feature, not a bug — but it requires discipline at the queryset layer. A clean-looking viewset with `objects.all()`\n\nis often hiding a query storm one serializer away.\n\nThe rule of thumb: **every relation accessed in a serializer needs a corresponding select_related or prefetch_related on the queryset.** Make it a checklist item on every PR that touches a ViewSet.", "url": "https://wpnews.pro/news/how-i-caught-and-fixed-an-n-1-query-in-my-django-rest-api", "canonical_source": "https://dev.to/highcenburg/how-i-caught-and-fixed-an-n1-query-in-my-django-rest-api-36p5", "published_at": "2026-05-23 11:51:01+00:00", "updated_at": "2026-05-23 12:01:30.173887+00:00", "lang": "en", "topics": ["developer-tools", "data", "enterprise-software"], "entities": ["Django", "Sentry", "DRF", "BlogPost", "BlogSeries", "BlogTag", "BlogPostViewSet", "BlogPostSerializer"], "alternates": {"html": "https://wpnews.pro/news/how-i-caught-and-fixed-an-n-1-query-in-my-django-rest-api", "markdown": "https://wpnews.pro/news/how-i-caught-and-fixed-an-n-1-query-in-my-django-rest-api.md", "text": "https://wpnews.pro/news/how-i-caught-and-fixed-an-n-1-query-in-my-django-rest-api.txt", "jsonld": "https://wpnews.pro/news/how-i-caught-and-fixed-an-n-1-query-in-my-django-rest-api.jsonld"}}