django-orm-lens nplusone --path . (alias: n-plus-one) walks every .py file in the workspace — skipping migrations/, virtualenvs, node_modules/, hidden directories, site-packages, dist, build, eggs — parses each with ast.parse, and flags the classic Django N+1 shape: a for loop over a queryset whose body accesses a related object per iteration, with no matching select_related / prefetch_related on the source queryset. No Django boot, no database, no code execution.
The scanner resolves the loop source two ways: inline chains (for p in Post.objects.filter(...):) and variables bound earlier to a queryset chain (qs = Post.objects.all() … for p in qs: — the most recent binding per name, tracked per function scope). Inside the loop body it collects accesses rooted at the loop variable:
p.author.name— attribute chain, suggestsForeignKey/OneToOneFieldtraversal → needsselect_relatedp.tags.all()/p.tags.filter(...)/.exclude/.count/.exists— related-manager idiom → needsprefetch_relatedp.comment_set...— the_setreverse-manager naming → needsprefetch_related
Accesses already covered by a select_related(...) / prefetch_related(...) clause in the chain are not flagged — string arguments are matched by their first lookup segment, a bare .select_related() counts as covering every FK, and Prefetch("lookup", ...) objects are understood. Each finding includes a rendered suggested_fix, e.g. .select_related("author").prefetch_related("tags").
The scanner also resolves a loop whose source is a call, not a chain — the shape a large share of real Django code actually uses:
def recent():
return Post.objects.filter(published=True)
for post in recent(): # source is a call
print(post.author.name) # still an N+1Resolution is one hop within the same module, by name. It covers helper(), self.get_queryset() and cls.build(), a helper that returns a local binding (qs = Post.objects.all(); return qs), a return from inside an if, and a helper defined after its caller — textual order does not decide what the scanner can see.
Deliberate limits, so the tool does not guess:
- A nested
def's return is never attributed to the function enclosing it. - A call to a name the module does not define —
fetch_from_cache()— is left alone rather than assigned an invented model. - A helper returning a list, an int, or anything that is not a QuerySet chain is not a queryset source.
Fixes count from either side of the call. recent() with select_related inside the helper is silent, and so is recent().select_related("author") applied by the caller — the helper's chain and the caller's are spliced before the check. A false positive on a queryset the author already fixed is worse than a miss, because it teaches people to ignore the tool.
- high — the loop source resolved to a model known to the parsed workspace schema, and the accessed attribute is a declared
ForeignKey/OneToOneField/ManyToManyFieldor a computed reverse relation (related_nameor default<model>_set). The unambiguous<attr>_setreverse-manager idiom also counts as high even without schema resolution. - medium — structural heuristics only: the source model could not be resolved, and the access pattern (attribute chain,
.all()on an attribute) merely suggests a relation.
When the source model is known but the attribute is not a declared relation, the scanner assumes a scalar and stays silent — it prefers missing a finding over inventing one.
django-orm-lens nplusone --path . # all findings, text output
django-orm-lens nplusone --confidence high # schema-confirmed findings only
django-orm-lens nplusone --format sarif > npo.sarif # SARIF 2.1.0 for GitHub Code Scanning
django-orm-lens nplusone --format github # ::warning workflow commands for PR annotations--confidence high|medium|all(defaultall) — minimum confidence to report;mediumincludeshigh. Usehighin CI to keep the gate noise-free.--format text|json|sarif|github— same output surface asmigration-risk.- Exit code:
1when any finding remains after the confidence filter,0otherwise;--exit-zeroalways exits0.
This is a static heuristic, not runtime query capture — it never sees the SQL your app actually runs. Known blind spots:
- Only the most recent assignment of a queryset variable is tracked; re-bindings, querysets passed across function boundaries, and querysets returned from helpers are not followed.
- Properties and methods that query the database internally are invisible; conversely, a
@cached_propertyor an attribute cached earlier may be flagged although it costs nothing. - Loops over unresolvable iterables (dicts, lists, ranges, unrecognized calls) are skipped entirely rather than guessed at.
- Attribute chains are evidence, not proof — a medium-confidence finding can be a plain object attribute. That is what the confidence filter is for.
Treat findings as review pointers with a suggested fix attached, and pair the detector with a runtime tool (e.g. query logging in tests) when you need proof.