Changelog¶
0.6.2 (2026-10-04)¶
Detection¶
.get()loops are also counted per line in each function of your code that leads to the call site. Calling a helper that runs.get()once from each of two lines is no longer reported asget_in_loop. A helper called in a loop and a.get()in a recursive function are still reported.- Queries from the
ContentTypemanager methods that fill Django's content type cache,get_for_model(),get_for_models(),get_for_id()andget_by_natural_key(), count toward neitherget_in_loopnorduplicate_query. Aprefetch_related()of aGenericForeignKeyon a cold cache, as after aTransactionTestCaseflush, is no longer reported.
0.6.1 (2026-10-02)¶
Detection¶
NPLUS1_THRESHOLDcounts rows, not reads. One row read through two instances, such as one from.get()and one from a queryset, is no longer reported as an N+1..get()calls are counted per call site and the calls that lead from it to.get(). A library call that looks a row up a second way is no longer reported asget_in_loop, as when waffle creates a missing switch withget_or_create(), wagtail's redirect middleware retries a path without its query string, orContentType.objects.get_for_model()creates a missing content type. Calling such a library function in a loop is still reported.
0.6.0 (2026-10-02)¶
Breaking changes¶
- The pytest marker and the
nplus1fixture treatNPLUS1_WHITELISTas the middleware does. Its model patterns match"app_label.ModelName"only, so entries such as{"model": "Occupation"}or{"model": "Occ*"}no longer match there. An entry naming an unknown model raisesNPlus1Error, which fails a test with the marker and errors a test with the fixture at setup. The marker's ownwhiteliststill matches class names. - The middleware,
setup_celery_detection()and every detection scope raiseImproperlyConfiguredwhendjango_nplus1is missing fromINSTALLED_APPS. Without the app, detection found nothing and said nothing.
Detection¶
- A foreign key column deferred with
.only()or.defer()and read byprefetch_related(), which loads it one row at a time, is reported as an N+1. This covers the rows of the queryset, the rows of aPrefetch()queryset, and.iterator()chunks. - A relation read in a loop is reported even when its rows were each fetched on their own earlier in the scope, for example by
.get()or an earlier lazy load, and then loaded again in one query. .get()calls that raiseDoesNotExistorMultipleObjectsReturnedcount towardget_in_loop.- Loops over
.aiterator()are detected. - Async ORM calls such as
aget()take the call site of the line in your coroutine that awaited them.aget()loops are reported asget_in_loopagain, and separateaget()calls in an async view requested from sync code, such as the test client, are no longer reported as one loop. - Rows loaded in an enclosing scope count in nested scopes. Reading a relation row by row in an inner scope is reported, and reads split between the scopes add up.
select_related()on an.iterator()queryset is no longer reported as an unused eager load when the loop reads the relation on early rows only.- With Django 6.1's
FETCH_PEERS, a deferred field orGenericForeignKeyread in a loop loads every row in one query and is no longer reported as an N+1. WithFETCH_RAISE, a blocked read of a deferred field no longer counts as a load.
Settings¶
- New
NPLUS1_PROJECT_PACKAGESnames packages that count as your code even when they are installed insite-packages, as in some Docker images. Without it, such projects got no.get()loop or duplicate query detection, and N+1 messages had no file and line. See Configuration.
Reporting¶
nplus1_detectedis sent withsend_robust(). A receiver that raises is logged on thedjango.dispatchlogger instead of breaking the code that made the detection, and async receivers work in async views.- A scope whose block raises no longer reports unused eager loads, because the error can stop the block before it reads them. They used to be logged, warned about and sent with
nplus1_detected, and only the raise was skipped. - A
duplicate_querymessage keeps the whole query in.field, so whitelist patterns match all of it. The message text still shows the first 120 characters.
Celery¶
- Two runs of the same task id in different threads at once no longer end each other's detection scope.
- The
ImportErrorraised without Celery quotes the install command,pip install "django-nplus1[celery]", which zsh needs.
0.5.0 (2026-10-02)¶
- Breaking: A detection that code inside a scope catches, as Django's
{% if %}tag does when a comparison raises, now fails the scope when it ends. This applies toNPlus1MiddlewarewithNPLUS1_RAISE,Profiler,DetectionContext,@pytest.mark.nplus1and thenplus1fixture. The caught detection replaces an exception that the block raises later, but never aBaseExceptionsuch asKeyboardInterrupt. - Breaking: With the marker or the fixture, a test fails on a detection even inside
pytest.raises(NPlus1Error). To check that code makes an N+1 query, use aProfilerin a test without them, as in Asserting a Detection. - Breaking: A detection raised when a Celery task ends, such as an unused eager load, now fails the enclosing scope if the task runs inside one, such as a request under
NPlus1Middleware, a test with the marker or the fixture, or another task. The task used to log it at ERROR level. A task runs inside the scope that calls.apply(), or.delay()undertask_always_eager, so tests that run tasks eagerly can now fail. - A fixture of your own that yields inside
with Profiler():can't see the test's exception, so a detection that fails the test fails its teardown too. Have it request thenplus1fixture instead. - A detection that a Celery task catches is logged at ERROR level when the task ends. If the task runs inside another scope, that scope raises it instead. The log message reads
detection not raised by task <id>instead ofdetection at the end of task <id> raised. - When a test that uses the
nplus1fixture fails or errors at setup, it no longer also errors at teardown on an unused eager load. The failure can stop the test before it reads the eager load. - With
NPLUS1_RAISE, a Celery task that fails no longer logs an unused eager load found when it ends, for the same reason.
0.4.0 (2026-09-29)¶
Breaking changes¶
- Invalid settings raise
ImproperlyConfiguredwhen the middleware is created or Celery detection is set up. This covers a threshold that is not an integer of at least 1, anNPLUS1_LOG_LEVELthat is neither a number nor a level name, anNPLUS1_LOGGERthat is neither a logger nor a logger name, and anNPLUS1_ERRORthat is not an exception class or a path to one. A threshold such as0or"2"used to turn detection off without a word.ProfilerandDetectionContextcheck the thresholds they use when they are entered. - Detection scopes nest. A detection in an inner scope reaches the notifiers of every enclosing scope, where notifiers built from the same settings report it once, and a whitelist entry of any of them suppresses it. With
NPlus1Middlewareinstalled,@pytest.mark.nplus1, thenplus1fixture andProfilernow see N+1 queries in views that a test requests through the test client. Tests that passed because the middleware hid those queries from them can now fail. nplus1_allow([])suppresses nothing. Onlynplus1_allow()without an argument suppresses every detection.- Listeners of the
EAGER_LOADsignal receive(model, field, keys, group, call_site), and instance keys have the formapp_label.ModelName:pk.
Corpus mode¶
pytest --nplus1-eager-corpus, orNPLUS1_EAGER_CORPUS = True, collects eager loads and loaded fields across the whole session. At the end it reports the ones that no test read and fails the session. Unusedselect_related()andprefetch_related()calls are reported asunused_eager_load, and columns that.only()or.defer()could skip asunused_field_load. See Corpus Mode.- A finding names the line that declared the eager load or started the queryset. A
# nplus1: corpus-ignorecomment on that line suppresses it.NPLUS1_FIELD_EXCLUDEskips whole models, andNPLUS1_WHITELISTapplies. - Works with pytest-xdist. Workers hand their findings to the controller, so no files are written.
- New
FIELD_LOADandFIELD_TOUCHsignals feed the field tracking.
Detection¶
- N+1 queries and unused prefetches on
GenericRelationmanagers are detected. - A
GenericForeignKeyread in a loop is reported as an N+1 on the relation, such asTag.content_object. It was reported asget()in a loop on the target model. - Loops over
.iterator()are detected. - A deferred field read in a loop is reported once, as an N+1 on the field. It was also reported as
get()in a loop, which fired at the second read whateverNPLUS1_THRESHOLDsaid, and a whitelist entry for the field didn't stop it. - Eager loads that are read are no longer reported as unused. This affected
Prefetch(to_attr=...)lists that are iterated, indexed, measured or searched,GenericForeignKeyprefetches,.count()and.exists()on a prefetched relation, andselect_related()through proxy models, multi-table inheritance parents, self-referential reverse one-to-one relations andFilteredRelation. - Reading a relation loaded with
select_related()is no longer reported as an N+1 when the same rows were loaded earlier in the scope. - Rows of models with the same class name in different apps are told apart.
- Call sites no longer point into django-nplus1's own code when the package is imported through a symlinked path.
- Duplicate query detection runs under the async middleware and on every database connection, not only
default. SQL given as bytes or as a psycopgsql.Composedobject no longer raisesTypeErrorafter the query has run. The query text in the message is cut to 120 characters. - Duplicate query detection skips queries Django runs while opening a connection, such as
django.contrib.postgres' hstore and citext type lookups, and queries with no frame of your code on the stack. Call sites no longer fall back to the standard library or a launcher line such as.venv/bin/pytest:10, which merged unrelated library queries into one call site. Async ORM calls run in a worker thread without your frames, soaget()loops are no longer reported asget_in_loop.
Suppression and scopes¶
- A suppressed read no longer uses up the one report per model and field, so a later N+1 in the same scope is still reported. With
NPLUS1_SHOW_ALL_CALLERS, an ignore comment suppresses a detection only when every listed call carries it. nplus1_allow()also suppresses unused eager loads.- A whitelist entry for a model also covers its proxy models and multi-table inheritance children.
Profiler,nplus1_allow(), and the marker'swhitelistmatchmodelpatterns against"app_label.ModelName"as well as the class name, so{"model": "auth.User"}works there too.- In
duplicate_querywhitelist entries,[in thefieldpattern matches a literal bracket. DetectionContextaccepts whitelist entries as dicts, likeProfiler. It crashed at the first detection.- Rows loaded in a scope can be read in a nested scope without being reported as an unused eager load.
- Entering a scope that is already active raises
RuntimeError. It used to leak the scope's listeners. - Prefetches running in several threads of one scope at once no longer hide lazy loads in the other threads, and no longer switch N+1 detection off for the rest of the scope.
- A detection raised when a
ProfilerorDetectionContextexits no longer replaces an exception from its body, and no longer skips tearing down the remaining listeners. Previously an unused eager load at exit left duplicate query detection attached to the connection.
Reporting and settings¶
Profileracceptsnotifiers, which run before it raises, for example to also log the detection.NPLUS1_LOGGERaccepts a logger name,NPLUS1_LOG_LEVELa level name such as"ERROR", andNPLUS1_ERRORa dotted path to an exception class. Strings used to crash the request at the first detection.- With
NPLUS1_SHOW_ALL_CALLERS, the calls listed in a message no longer change after it was sent, andNPLUS1_WARNwarnings point at the line that triggered the detection instead ofdjango_nplus1:0.
Middleware¶
NPlus1Middlewareis a class. TheMIDDLEWAREentry stays"django_nplus1.NPlus1Middleware".NPLUS1_WHITELISTis checked when the middleware is created, at startup instead of on the first request. An unknown model still raisesNPlus1Error. An unknown field only warns, because aPrefetch(to_attr=...)name is no model field. Column names such asuser_idand model classes are accepted.- With
NPLUS1_RAISE, an exception raised by the view is no longer replaced by a detection made at the end of the request.
Celery¶
- Invalid settings raise when detection is set up, which happens at startup with
NPLUS1_CELERY = True. They used to turn detection off for every task, with a DEBUG log line. When detection can't start for a single task, the task still runs and the error is logged at ERROR level. - A detection made when a task ends, such as an unused eager load, is logged at ERROR level on the
django_nplus1logger instead of raised. Celery can't fail a task that has already finished. - An eager
self.replace()no longer leaves its detection scope active.
pytest plugin¶
@pytest.mark.nplus1checks only the test body. Previously its profiler also covered fixtures, including pytest-django's test database setup, sopost_migratehandlers and data fixtures could fail the test at setup, and the failure stayed cached for later database tests on the same worker. The autouseauto_nplus1fixture is replaced by apytest_runtest_callhook.- The pytest marker and
nplus1fixture applyNPLUS1_WHITELIST.
ORM patches¶
These apply as soon as django_nplus1 is installed, also outside detection scopes.
- Related querysets, and instances with prefetched relations, can be pickled and deep-copied.
- A related manager called with
manager=, as inuser.hobbies(manager="objects"), no longer raisesTypeError. - Calling
.all()many times on a prefetched relation no longer ends inRecursionError. - Related querysets are freed without waiting for the garbage collector.
Compatibility¶
- Supports Django 6.1.
0.3.5 (2026-05-20)¶
- A deferred field read in a loop is reported even when the same rows were also fetched one at a time in the scope, for example with
.get()orrefresh_from_db(). Such a fetch used to hide the N+1.
0.3.4 (2026-04-20)¶
- Django no longer fails to start when an imported module's
__getattr__raises an error such asKeyError, as ddtrace's does. The failure started in 0.3.3.
0.3.3 (2026-04-18)¶
- The 0.3.2 fix also applies in modules that import
prefetch_related_objectsfromdjango.db.modelsbefore django-nplus1'sAppConfig.ready()runs, such asmodels.pyfiles.
0.3.2 (2026-04-18)¶
prefetch_related_objects()with lookups that end on the same relation, such asstore__regionandwarehouse__region, is no longer reported as an N+1. Calling it on one row at a time in a loop is still reported.
0.3.1 (2026-04-16)¶
- Fix forward M2M without an explicit
related_name: detection now uses the correct field name instead of the auto-generated one.
0.3.0 (2026-04-14)¶
- Inline
# nplus1: ignoresuppression. Add a trailing comment to the call site to suppress a detection, optionally scoped to labels (# nplus1: ignore[n_plus_one, get_in_loop]). - Fix false positive on
qs.prefetch_related(...).filter(pk=X)where a queryset-level prefetch returning a single instance was flagged as N+1.
0.2.1 (2026-04-14)¶
- Breaking: Requires Python 3.14+ and Django 6+. Python 3.12, Python 3.13 and Django 5.2 are no longer supported.
0.2.0 (2026-04-13)¶
- Celery integration: per-task N+1 detection via
task_prerun/task_postrunsignals. Enable withNPLUS1_CELERY = Trueor by callingdjango_nplus1.celery.setup_celery_detection(). Theceleryextra (pip install "django-nplus1[celery]") installs Celery. - Extract
DetectionContextas a reusable public class for scoped detection.
0.1.0 (2026-04-12)¶
Initial release.
- N+1 lazy load detection for related fields (ForeignKey, OneToOneField, ManyToManyField)
- N+1 detection for deferred field access (
.defer()/.only()) .get()in a loop detection- Unused eager load detection (
select_related/prefetch_related) - SQL-level duplicate query detection as opt-in fallback (
NPLUS1_DETECT_DUPLICATE_QUERIES) - Call-site tracking in detection messages
NPLUS1_SHOW_ALL_CALLERSmode for full stack traces- Configurable thresholds (
NPLUS1_THRESHOLD,NPLUS1_GET_THRESHOLD,NPLUS1_DUPLICATE_QUERY_THRESHOLD) - Django middleware with sync and async support
- pytest plugin with
nplus1fixture and@pytest.mark.nplus1marker Profilercontext managernplus1_allow()context manager for local suppressionnplus1_detectedDjango signal for custom reporting- Whitelisting with wildcard support and validation against Django model registry
- Multiple notification methods: logging,
warnings.warn_explicit(), raise exception - Python 3.12+ / Django 5.2+ support