Default severity: warning · Applicability: unsafe · Category: queryset
Detects for x in <queryset>: followed by an x.<attr> access in the loop body (first 15 lines; id, pk, save and underscore-prefixed attributes are ignored, one finding per loop). If <attr> is a ForeignKey / OneToOneField, each iteration triggers a separate query — the classic N+1: one query for the loop plus one per row. select_related() (SQL join) or prefetch_related() (batched second query) collapse that into constant query count.
The rule remains unsafe and ships no QuickFix: the fix is a judgement call about how the queryset is used, not a mechanical edit.
Until 0.18.0 the rule was pure line shape, so it fired on a plain column, on an inherited Python property, and even on a foreign key the same statement already spanned — #85. Two gates now match what the CLI’s nplusone analyzer has always done, and they are independent on purpose.
Scalar (needs the workspace index). An attribute the schema declares as a non-relational field is skipped. So is an attribute a known model does not declare at all — that is what a @property on an abstract base looks like from the outside, and guessing “relation” there is what produced most of the false positives. Before the first workspace scan finishes this gate is simply off, so a cold start behaves as it did before rather than reporting confidently from a schema it does not have yet.
Already covered (needs nothing). A relation named in .select_related(...) or .prefetch_related(...) on the chain that produced the loop is skipped, including a bare .select_related(), which spans every FK. This is pure string matching, so it holds on a cold start too — and it is the gate that stops the rule contradicting code that has already taken its own advice.
The chain is looked for on the binding as well as on the loop head, up to 40 lines above it and across line continuations, because codes = list(OneTimeCode.objects.select_related("issued_by")) followed by for code in codes: is the ordinary way this is written. The walk stops at a def/class boundary and at a binding it does not recognise, rather than falling through to an older binding of the same name.
A model the index does not contain is not read as “scalar” — the rule falls back to its pre-0.18 behaviour there, so a partial scan cannot silence real findings.
The loop head is read as text, and the decision is made from the iterable alone.
An iterable containing no ( — a bare name (for user in users:) or a dotted chain (for entry in self.pending:) — is in scope. It names no call, so a single line carries no evidence either way, and users = User.objects.all() is the common idiom. A name bound to a list is in scope for the same reason, so k_entries = ["a", "b"] followed by for e in k_entries: print(e.upper) is reported.
An iterable that does contain a ( is in scope only when the text before that first ( is exactly three dotted segments with objects in the middle (<Model>.objects.<method>), or a dotted chain whose final segment is a queryset-producing method (all, filter, exclude, annotate, distinct, order_by, values, values_list, reverse, none, iterator, only, defer, using, alias, dates, datetimes, union, intersection, difference, select_related, prefetch_related, extra, select_for_update, raw).
Every other call-shaped source is skipped: range(10), os.listdir(path), apps.get_models(), and a helper call such as auditory_models() or self.get_queryset(). The call name alone says nothing about what comes back, and finding out means following the callee’s return, which needs the AST this line-oriented rule does not have. The cost is a false negative — a helper that does return a queryset is skipped too.
for post in Post.objects.all():
print(post.author.name) # one extra query per post
for post in Post.objects.select_related("author"):
print(post.author.name) # single joined query
# orm-lens-disable-next-line DOL007
Or per-workspace in .vscode/settings.json: {"djangoOrmLens.rules": {"DOL007": "off"}}.