N+1 detection and devx_doctor¶
Two independent tools: ninja_devx.contrib.nplusone catches N+1 queries at runtime (an
adapter over django-zeal), and
manage.py devx_doctor finds configuration risks in mounted controllers that
manage.py check does not, such as an unindexed filter field or a list endpoint with no
pagination.
Runtime N+1 detection¶
Install the extra and add "zeal" to INSTALLED_APPS:
Add the plugin so every request runs inside zeal's tracking context:
from ninja_devx.contrib.nplusone import NPlusOnePlugin
from ninja_devx.plugins import install
install(api, [NPlusOnePlugin()])
Or add just the middleware, to one controller or router:
from ninja_devx.contrib.nplusone import NPlusOneMiddleware
ControllerOptions(middleware=[NPlusOneMiddleware()])
- Detection stays entirely in
zeal; nothing here re-implements query counting. - When zeal raises
NPlusOneError, the middleware rewrites the message to name the relation and suggest the fix inninja_devx.crud.optimizationterms:
N+1 detected on blog.Article.author at blog/api.py:42 in list
Fix in ArticleController.list: add related = ('author',) to the controller, or
@requires_related('author') on the resolver that reads it (see
ninja_devx.crud.optimization).
- Tune detection with zeal's own settings:
ZEAL_RAISE(raise instead of warn, default on),ZEAL_NPLUSONE_THRESHOLD,ZEAL_ALLOWLIST. See its README forzeal_ignore()and thenplusone_detectedsignal. - Everything in
ninja_devx.contrib.nplusoneis a no-op whenzealis not installed, so the plugin is safe to add unconditionally.
Failing tests on N+1¶
def test_list_has_no_n_plus_one(strict_queries, ninja_client):
ninja_client(ArticleController).get("/")
strict_queries (a pytest fixture, auto-loaded with ninja_devx) runs the test inside
zeal with ZEAL_RAISE forced on, regardless of the project's own setting, and skips with
a clear reason when zeal is not installed.
manage.py devx_doctor¶
$ python manage.py devx_doctor
warn ArticleController Article.title is filtered, searched or ordered on without a database index
warn ArticleController the list endpoint has no pagination_class configured
info ProjectController tenant_field 'organization' is set, but no tenant_resolver/tenant_context is configured on the controller or NINJA_DEVX
warn NoteController Note.slug is globally unique, but NoteController soft-deletes rows: a deleted row's value can never be reused
It inspects the same mounted controllers as manage.py check
(System checks), through NINJA_DEVX["CHECK_APIS"], and reports:
| Severity | Finding |
|---|---|
| warn | a search_fields/filter_fields/ordering_fields/default_ordering field has no database index, unique constraint, or is not a primary key or foreign key |
| warn | owner_field is set, but IsOwner is not enforced and object_permissions is not configured |
| info | tenant_field is set, but no tenant_resolver/tenant_context is configured on the controller or NINJA_DEVX |
| warn | an output field is filled by a resolve_<field>() the N+1 planner cannot analyse, and no related/@requires_related hint covers it |
| warn | a list endpoint has no pagination_class configured |
| warn | a controller has no permission configured at all |
| warn | a SoftDeleteMixin model has a globally unique field with no soft_delete_unique constraint |
Pass a controller path or a mounted route prefix to check just one:
--jsonemits the findings as a list of{severity, controller, message, hint}objects instead of the table.--fail-on warnexits 1 when awarn(or more severe) finding exists;--fail-on infoexits 1 on any finding. Without it,devx_doctoralways exits 0 and is safe to run informationally in CI.- Findings are informational: nothing here is wrong the way a
manage.py checkerror is, which is why it is a separate command instead of another system check.
Add a project-specific finding by appending a function to ninja_devx.tooling.doctor.CHECKS: