Open source · Python package
drf-prefetch-hint
Tells you exactly which select_related/prefetch_related to add to your DRF viewset.
Other tools tell you that you have an N+1. This one writes the fix.
pip install drf-prefetch-hint Django 4.2 / 5.0 / 5.1 · DRF 3.14+ · dev-only, no middleware, no runtime hook.
The demo
One command. The whole answer.
Point it at a viewset. It reports every relation your serializer touches, what kind it is, how many queries it cost, and where in your code it came from — then hands you the ORM expression.
$ python manage.py prefetch_hint shop.views.AuthorViewSet --count 10 AuthorViewSet — 51 queries on 10 objects books.reviews reverse FK → prefetch_related 20 queries shop/views.py:7 books reverse FK → prefetch_related 10 queries shop/views.py:11 publisher.name FK → select_related 10 queries shop/views.py:10 summary method → 10 queries, manual fix needed 10 queries shop/views.py:12 Add to get_queryset(): .select_related("publisher") .prefetch_related(Prefetch("books", queryset=Book.objects.prefetch_related("reviews"))) Projected: 51 → ~13 queries (estimate) Not auto-fixable: summary — SerializerMethodField runs arbitrary code
Real, selectable text — not a screenshot. Scroll the panel sideways on narrow screens.
The result
pasting the suggestion verbatim
Projected ~13; the real result is usually better than projected, because a prefetch often satisfies method-field queries for free.
The change
Before and after
class AuthorViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = AuthorSerializer
def get_queryset(self):
return Author.objects.all() from django.db.models import Prefetch
from shop.models import Author, Book
class AuthorViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = AuthorSerializer
def get_queryset(self):
return (
Author.objects.all()
.select_related("publisher")
.prefetch_related(
Prefetch("books", queryset=Book.objects.prefetch_related("reviews"))
)
)
You supply two imports the tool can't add — Prefetch, and any model used inside Prefetch(queryset=…).
The hard part
Nesting, not flattening
reviews hangs off books, which is itself a reverse FK — so it belongs on the inner Prefetch queryset, not flattened onto the outer one. Working that out by hand is the hour you don't spend.
Internals
How it works
- 01
Wrap DRF’s
get_attributeandto_representation; keep aContextVarstack of the field currently resolving. - 02
Django’s
connection.execute_wrappersees every query fire and tags it with whatever is on top of that stack — every query attributed to the exact serializer field that caused it. - 03
Walk those field paths through
model._metato decide JOIN vs. second query. - 04
Assemble the ORM expression and
ast.parse-validate it before printing.
Everything runs inside a transaction that is always rolled back; DRF is left unpatched on every exit path including exceptions.
Usage
Flags
| Flag | Default | What it does |
|---|---|---|
| --count | 25 | How many objects to build the sample queryset with. |
| --user | AnonymousUser | The user the profiled request runs as. |
| --action | list | The viewset action to profile. |
| --raw | — | Raw output, without the formatted report. |
| --no-color | — | Disable ANSI colour in the output. |
| --force | — | Run anyway when DEBUG is False. Refuses without it. |
- --count default 25
- How many objects to build the sample queryset with.
- --user default AnonymousUser
- The user the profiled request runs as.
- --action default list
- The viewset action to profile.
- --raw
- Raw output, without the formatted report.
- --no-color
- Disable ANSI colour in the output.
- --force
- Run anyway when DEBUG is False. Refuses without it.
Honest limits
Limitations
Method fields can't be resolved
SerializerMethodField runs arbitrary code. It's reported and marked manual fix needed — the tool won't guess, because a wrong guess is worse than silence.
The projection is an estimate
Not a promise. The printed number is what the analysis expects, not a measured result.
One code path only
Suggestions reflect the one code path that ran, with the user and action you passed. A different --user or --action can produce a different answer.
Development only
It refuses to run under DEBUG=False without --force.
Scope
What it does not do
It's a one-shot answer machine, not a monitor. These tools cover the jobs it deliberately leaves alone.
- django-debug-toolbar ↗ Browse-time watching
- django-query-guard ↗ CI regression
- django-perf-rec ↗ CI regression
- zealot / django-zeal ↗ Runtime N+1 detection
- Sentry ↗ Production tracing
- Scout ↗ Production tracing
DRF serializers only — not plain class-based views, the Django admin, GraphQL, or Django Ninja.
Try it
One command away.
pip install drf-prefetch-hint Bug reports with a minimal reproducing serializer are the most useful thing to send.