Changelog¶
Auto-included
This page pulls the changelog content directly from the CHANGELOG.md
file at the repository root. Changes are maintained in a single location
and rendered here automatically by MkDocs.
Changelog¶
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Two sections below describe releases that were never published:
[1.0.3]and[2.0.1]. There are twelve version sections here and ten git tags, and the two without a tag are the same two with no artifact on PyPI. If you are choosing a version to install, those two are not installable; use1.0.2or2.0.0respectively. The sections are left in place rather than deleted, because released sections are never edited retroactively -- this note is the additive remedy.Section dates are the PyPI upload date. That holds exactly for 2.1.0 through 2.2.0. Three earlier headings are within a day of their upload, which is a timezone artifact (PyPI reports UTC, uploads were from UTC+5) and not an error;
2.0.0is dated two days before its upload and is simply wrong.
[2.3.1] - 2026-08-10¶
Fixed¶
missing_indexno longer suggests an index name Django rejects. The prescription built the name by interpolating the table and column with no length bound, andIndex.max_name_lengthis 30, enforced byModel._check_indexesfor every configured database with no backend gate. So pasting the suggestion mademanage.py checkfail withmodels.E034whenever the table and column together exceeded 25 characters, which is ordinary under Django's default{app_label}_{modelname}table naming; the repo's own probe produced a 31-character name. The index is now emitted unnamed, and Django'sset_name_with_modelbuilds one that is bounded by construction and hashed over the table and columns, which removes the class rather than capping it. One failure mode changed rather than appeared: the column is scraped from SQL rather than read from Django's metadata, and where it is not a model field the suggestion was already broken withmodels.E012at check time. An unnamed index is named at class creation, so that case now raisesFieldDoesNotExistwhenmodels.pyis imported.UPGRADING.mdnames the symptom.- The interceptor no longer leaks one
ContextVarper request.QueryInterceptor.__init__calledContextVar.set()and nothing ever undid it.set()stores the variable and its value in the running context, and under WSGI that context belongs to the worker thread and outlives every request the thread serves, so each request left one more variable behind -- each still holding that request's fullCapturedQuerylist. Dropping the interceptor did not help, because the context holds the reference rather than the caller. A long-running worker therefore accumulated both entries and captured query data without bound, contradicting the "no accumulation across requests" claim indocs/deep-dive/performance.md. The interceptor now keeps its token and exposesrelease(), which removes the entry from the context that created it; it is idempotent and never raises, so a token used from another thread logs a debug message instead of taking aValueErrorinto the host application. All nine construction sites -- both middleware paths,diagnose_queries(), the Celery task decorator, the pytest fixture, the project diagnoser, and the three management commands -- release in afinally, so a failed analysis does not leak either. - The 2.3.0 upgrade notes were wrong about
--fail-on-regression. They stated it was "unaffected: fewer findings cannot create a regression", four paragraphs after stating that the N+1 prescription text had changed. A baseline snapshot keys each issue onanalyzer : file_path : message(baseline.py), so the changed message rehashes every N+1 entry: the same unfixed finding is reported as resolved under its old key and as a new regression under its new one, and a CI job running--baseline=... --fail-on-regressionexits 1 with no code change. The identical mechanism was documented correctly for the 2.1.x upgrade in the same file.UPGRADING.mdnow says so, and names the regeneration step. check_serializersno longer prescribes a queryset call that raises. Of the seven placesserializer_methodbuilds a finding, only one established that the attribute it was about to name was a relation. The other four that prescribeprefetch_relatedtook the attribute straight from the source, soget_theme(self, obj): return obj.payload.get("theme")producedprefetch_related('payload'), which raisesAttributeError; aCharFieldproducedprefetch_related('title'), which raisesValueError. All four now resolve the attribute againstMeta.model, or against Django's_setreverse-accessor suffix when the serializer has no model, and suppress the finding when neither establishes a relation.- The deep-chain check no longer prescribes
select_relatedfor relations it cannot take.select_relatedfollows a single forward join, soobj.categories.nameon a many-to-many producedselect_related('categories')andFieldErrorwhen followed. It now requires a forwardForeignKeyor aOneToOneField, and a serializer with no model gets no prescription here at all, because a reverse accessor is a prefetch signal rather than aselect_relatedone.
[2.3.0] - 2026-08-01¶
Added¶
reset_turbo_override()is exported fromquery_doctor.turbo, alongsideset_turbo_override()andget_turbo_override(). 2.2.0's release notes pointed users atset_turbo_override()as the replacement for the removedturbo.patch.set_thread_override, but the token it returns could not be reset from outside the module: resetting aContextVarneeds the variable itself, and_turbo_overrideis private. The manual override is now actually usable, is documented in the QueryTurbo guide, and is covered by tests.turbo_enabled()andturbo_disabled()now route throughset_turbo_override()rather than setting the ContextVar directly, so the helper the changelog recommends has a caller in the package.- The console reporter prints a one-line note when a report contains both an N+1 finding and a fat SELECT finding, saying that fixing the N+1 widens the base query and that the fat SELECT findings should be re-read afterwards. Prescriptions are now returned in the order they should be applied rather than in analyzer-discovery order: N+1 first, fat SELECT last.
check_serializers' loop check and the N+1 analyzer's relation resolution are covered by tests that run the prescribed queryset call against real Django, so a prescription naming a field that does not resolve fails with the error Django raises rather than passing a string comparison.
Changed¶
docs/deep-dive/architecture.mdno longer shows a fabricated console block. The section illustrated the console reporter with hand-written output the tool has never produced: three of its four distinctive strings (QUERY DOCTOR REPORT,N+1 Query Detected,match fingerprint) appear zero times insrc/, and the fourth (Total queries:) appears on 3 lines but only as part of a differently shaped summary line. It is replaced with an excerpt of the real committed capture, and the surrounding prose now describes the Rich and plain paths as the different renderings they are rather than claiming they print the same block.- The QueryTurbo speedup table is re-measured and correctly labelled. The
published figures (123x / 153x / 294x / 374x / 214x / 1,050x) did not
reproduce with the documented command: every value came back 30-50% lower,
and the complex scenario measured 523.4x -- below the 727x floor that the
"hardware variance" note disclosed, so the caveat failed too. The table is
replaced with a single run on a named machine, is explicitly labelled as the
compilation_onlysection ofbenchmarks/results.json, and the docs now state up front that the last line the command prints is the end-to-end result and comes out below 1x on SQLite in-memory, explaining why that is expected rather than a defect. That end-to-end figure is given as a run with its observed run-to-run spread rather than as a single value, since publishing one point estimate as a constant is the fault this entry was about. The table was duplicated on two pages; it now lives on one, with the other linking to it. docs/guides/auto-fix.mdlists all nineIssueTypemembers instead of seven, and marks which are selectable via--issue-type. The guide said "five of the seven issue types", omittedserializer_method_field(shipped in v2.0) andwrite_n_plus_one(shipped in 2.2.0), and described--issue-type complexityas accepted-but-fruitless when argparse rejects it outright -- contradicting the guide's own statement two paragraphs below that an unknown value "is rejected with an error before anything runs".docs/api/reference.mdautodocs all eight analyzers.WriteNPlusOneAnalyzerandSerializerMethodAnalyzerwere absent, so the API reference disagreed with the two other pages that count the analyzer set correctly.models_metais documented as reserved and alwaysNone. The parameter is part of theBaseAnalyzercontract and appears in all eight analyzers, but nothing in the package passes it: the only call site,pipeline.analyze(), callsanalyzer.analyze(queries).base.pydescribed it as "for enhanced analysis" and the plugin guide said it "may beNone" -- both of which invite a plugin author to write code against a value that never arrives. All six documentation sites and the base-class docstring now say it is alwaysNone. Removing the parameter would break third-party analyzer signatures, so it happens in a major version; see Deprecated below, where that removal is announced for 3.0.0.- Three documents quoted the
missing_indexTODO comment with an em dash the fixer does not emit (it writes an ASCII hyphen). Corrected, and pinned by a test that asserts the emitted separator and sweeps every tracked markdown file for a recurrence -- a test on the emitted string alone would have stayed green throughout, because the defect was never insrc/. docs/guides/async-support.mdbacks or withdraws the claims its own inventory flagged as asserted-unbacked.async with diagnose_queries()raising, and@query_budgeton a coroutine not enforcing, are now measured, each with a control. Backing the first corrected it: the guide said it raisesTypeError, which holds only on Python 3.11 and later -- on 3.10 the same code raisesAttributeError: __aenter__, so a reader on the oldest supported interpreter writingexcept TypeErrorwould not have caught it. Both types are now named, in both places the guide states the limitation. The concurrency and "not a change relative to 2.1.1" claims cite the tests that establish them. The connection-pooler andasyncpglimitations now say plainly that neither is exercised here. The claim that the interceptor'sContextVarstorage "does propagate acrossawait" is withdrawn rather than reworded: no code path shares an interceptor between contexts, so no test could distinguish it from thread separation, and the thread-locality of Django's connection registry is what decides the outcome anyway.CHANGELOG.mdgains a note at the top recording that[1.0.3]and[2.0.1]describe releases that were never published -- ten version sections against eight git tags, and the two without a tag are the two with no PyPI artifact. The sections are left in place; released sections are not edited retroactively, so the note is the additive remedy.benchmarks/is inside the gates. The v2.0 QueryTurbo suite sat outside the commands, which namedsrc/ tests/ scripts/, and failed them: 12 ruff errors and 14 mypy errors. Both are fixed and all three gate declarations -- CI, the pre-push hook, and the contributor docs -- are widened in the same change, so the errors cannot be reintroduced. Two waivers are recorded with reasons rather than left implicit:E501forbenchmarks/report.py, whose long lines are CSS and Chart.js inside an HTML template rather than Python, andattr-definedforbenchmarks.*, because the django-stubs plugin resolves models againsttests.settingswhere the benchmark app is deliberately not installed. Nothing shipped is affected: the wheel packagessrc/query_doctoronly.- A new gate keeps
src/and config free of em and en dashes. It ships green -- the baseline is zero -- which is the cheapest moment to install one; the exposure it closes is that the clean state was previously unenforced.scripts/dash_gate.pyclassifies by token kind, as CLAUDE.md prescribes, flagging only COMMENT tokens and docstring STRING tokens. That makes the program-output exemption fall out automatically rather than needing a maintained list:print()heredocs andtitle=arguments are not docstrings. It runs in CI and on pre-push, and is stdlib-only so it needs no install. Its own tests feed it dash-carrying input in every flagged position and in every exempt one, because a gate verified only against a clean tree is verified against nothing. - The CI matrix exercises the two claimed cells it was skipping. Python
3.10 x Django 5.1 and 3.10 x Django 5.2 were excluded despite Django
declaring
requires_python >= 3.10for both, so the README badge and the trove classifiers claimed combinations nothing tested. Only the two genuinely impossible cells remain excluded (Django 6.0 needs 3.12), taking the matrix from 16 to 18 jobs. - Two marketing-register sentences are replaced with checkable statements:
comparison.md's "provides the most comprehensive CI analysis" now says what it does thatnplusonedoes not, andcustom-plugins.md's "integrate seamlessly" now says what integration actually means.
Deprecated¶
models_metais deprecated. 3.0.0 removes it from theBaseAnalyzer.analyze()signature. Nothing has ever populated it. The sole call site,pipeline.py:92, callsanalyzer.analyze(queries)for every analyzer on every run, so the argument isNoneunconditionally, and all eight built-in analyzers ignore it. It is part of the plugin contract rather than an internal detail, which is the only reason its removal waits for a major version instead of happening in this release.
What a third-party analyzer author has to do. Nothing for 2.3.0: an
analyze(self, queries, models_meta=None) signature keeps working
unchanged. But since the argument is never passed, you can drop the
parameter today and be correct on both 2.3.0 and 3.0.0:
# accepted by 2.3.0, required by 3.0.0
def analyze(self, queries: list[CapturedQuery]) -> list[Prescription]:
...
If you keep the parameter, remove it when you adopt 3.0.0. If you read the
value and branch on it, that branch is unreachable today and should go now.
Do not add a None check waiting for a value to arrive, because none will.
The documentation that described the parameter was corrected in this release
rather than left to the deprecation: six doc sites and the base.py
docstring said "optional model metadata" or "may be None", which invited
exactly that None check. They now state that it is reserved and always
None, and docs/guides/custom-plugins.md and docs/contributing.md carry
this removal notice.
Fixed¶
check_queries --urlno longer exits 0 for a URL that does not resolve. Both aResolver404and any exception raised inside the view were swallowed identically, and the command went on to report zero captured queries and exit 0. For a tool whose CI story is "fail the build on new issues", analysing nothing was indistinguishable from finding nothing, so a typo in a--urlargument turned the gate green permanently. Each case now raises aCommandErrorwith its own wording, naming the URL. This can turn a previously-green CI gate red. If your pipeline runscheck_queries --urlagainst a path that does not resolve, or against a view that raises, the build will now fail where it previously passed -- which is the point of the fix. Check the URL before upgrading.- The N+1 analyzer no longer prescribes a field that raises
FieldError. On a repeated primary-key lookup the field name was derived by string-slicing the table name (testapp_author->author) whenever no foreign key was found, and the foreign-key path was no safer: it searched every model in the project for a field pointing at the target table, with no knowledge of which model the caller was iterating.Author.objects.get(pk=pk)in a loop prescribed.select_related('author'), which raises. A relation is now named only after it resolves throughModel._meta.get_field(), and only when an earlier query in the same capture read the table that declares it -- so a genuine forward-FK N+1 still getsselect_related, and a bareget(pk=...)loop gets the advice that actually applies: fetch the rows in one query within_bulk()orfilter(pk__in=...). The prescription text changes for both cases, and it now names the model the call belongs to. - The reverse-foreign-key branch of the same analyzer had the same defect,
found while fixing the above: for
WHERE "book"."author_id" = ?it read the field name off the column (author) and prescribedprefetch_related('author')on what is necessarily anAuthorqueryset. It now resolves the reverse accessor on the far side of the relation (books) and validates it against that model. - The
serializer_methodanalyzer no longer prescribesprefetch_related()for a loop over a scalar attribute.for ch in obj.titleover aCharFieldproducedprefetch_related('title'), which raisesValueError. The bare-attribute loop branch now confirms the attribute is a relation -- throughMeta.modelwhen the serializer has one, otherwise through Django's own_setreverse-accessor suffix -- and emits nothing when it cannot. - The
duplicateanalyzer no longer reports a re-read that follows a write to the same table. Read, write, read back is ordinary Django, and following the prescription ("assign the result to a variable and reuse it") returns the pre-write row. This was the one finding in the set whose fix was a correctness regression rather than a missed optimisation, so the group is suppressed rather than reworded. fat_selectno longer counts a joined table's columns against the base table. The column count came from the whole select list while the table name came from theFROMclause, soBook.objects.select_related("author")reported 13 columns "from testapp_book" when Book has 8, and the prescribed.defer()addressed only a fraction of them.fat_selectno longer fires on a single-row lookup.Book.objects.get(pk=1)returns one row and hit the default threshold of 8 with the model's own columns, so every read of an ordinary Django model produced a finding. A primary-key equality test or an explicitLIMIT 1is now exempt.extract_tables()reports the target table ofUPDATE,INSERT INTOandDELETE FROMstatements. It matched onlyFROMandJOIN, so every write reported no tables at all and was invisible to any analyzer reasoning about which tables a statement touches.write_nplusone's IN-list and VALUES counters are aware of quoted string literals.WHERE "name" IN ('a,b')counted two items, so the statement was classified as a bulk write and the finding was suppressed. Reaching this needs a literal inlined into the SQL through.extra(),RawSQLor a hand-writtencursor.execute(), because Django's ORM parameterises.- Embedding the middleware by hand around an async handler now emits a
QueryDoctorWarninginstead of silently reporting zero. On that route theexecute_wrapperis installed on the event loop thread's connection while Django runs async ORM work on a separate executor thread, soaget,acreate,acount,aexistsand async iteration were never captured and nothing said so. The condition is probed by asking the executor whether it can see the interceptor, not predicted, so an async handler doing sync ORM inline stays quiet. The warning describes the wiring rather than one request, so it is emitted at most once per middleware instance. Suites that escalate warnings to errors will fail on a hand-embedded async middleware. - Removed the dead
_severity_color()helper from the project report generator, and two parameters that were declared and never read:_suggest_simplification'sscore(whose docstring documented it) and_render_executive_summary'stotal_warnings. 2.2.0 removed five dead symbols, so a reader could reasonably assume the sweep was complete. tests/test_management_commands.pydrovecheck_queriesat/test/, which is absent from the test URLconf, so nine tests analysed zero queries and asserted against an empty report.test_baseline_no_regression_exits_zerocompared an empty baseline against an empty run and would have passed with--fail-on-regressiondeleted; it is rebuilt around a non-empty baseline and paired with a negative control that fails when a regression is introduced.diagnose_project's baseline path -- documented in the baseline guide and previously the largest uncovered block in the package -- now has tests for save, no-regression, regression and resolved-issue reporting.- The example artifacts no longer ship the author's local absolute paths.
examples/outputs/report.{html,json}andexamples/screenshots/*.capture.txtcarriedC:\Users\<user>\...and a pytest fixture directory including a Windows account display name, across 20 lines in 4 files. These are generated artifacts committed exactly as produced, and they ship inside the sdist, so the paths were published.scripts/regen_examples.pynow normalizes them -- the repository root becomes a repo-relative path with forward slashes, the fixture directory becomes<tmpdir>-- and asserts before writing that nothing leaked. All four artifacts are regenerated. The published 2.2.0 sdist is immutable and keeps its copies permanently; this fix applies from the next release onwards. The artifacts were not hand-edited: hand-editing a generated capture is what produced the fabricated files51c72cdhad to delete. tests/test_svg_capture_sync.pycovers the two artifacts it never touched (report.htmlandreport.json), asserts that no generated artifact carries an absolute local path in any of its spellings, and adds the staleness check that was missing: both captures are regenerated into a temporary directory and diffed against the committed copies, with durations masked because they legitimately differ between runs. The staleness check was only possible once the paths were normalized, since the fixture directory's counter changed on every run. It immediately earned its place: the console capture had drifted from the N+1 prescription wording changed above, and the transcription inexamples/generate_svgs.pywas corrected rather than the test relaxed.
[2.2.0] - 2026-07-30¶
Added¶
- An eighth built-in analyzer,
write_nplusone, detects repeated single-row writes — the.save(),.create()or.delete()in a loop that issues one round trip per object. The other seven analyzers all examineSELECTstatements, so this is the first one that fires on code doing no reads at all: an import job, a bulk status update, a fan-out of rows. It prescribes the bulk equivalent (bulk_create(),bulk_update(), a querysetupdate()ordelete()), naming the model where the table resolves to one. Enabled by default at a threshold of 3 identical writes; configure underANALYZERS.write_nplusone, and suppress individual findings with anignore: write_n_plus_one:<path>rule in.queryignore. Transaction control statements are excluded, so a request opening several transactions is not reported.fix_queriesdoes not rewrite these findings — the fix is a multi-line restructure, not a single-line edit, the same reasoncomplexityhas no fixer. See the Write N+1 analyzer guide. - The
query_doctorpytest fixture now produces observable output. Apytest_terminal_summaryhook prints aquery_doctorsection at end of session: one header line with the number of fixture-using tests observed and how many were clean, then one line per test that had findings. Tests with zero issues produce no line, so the section stays proportionate to the problems found. Previously the fixture's report was populated in a teardown finalizer and then discarded unread, giving the fixture no observable effect. The teardown timing is unchanged, sodiagnose_queries()remains the tool for assertions inside a test body; the fixture's own runtime warning about that is unchanged. docs/guides/async-support.mdnow documents the hand-embed route — buildingQueryDoctorMiddlewaredirectly around an asyncget_response, so__call__awaits__acall__— and gives its measured cost. The route itself is unchanged and is not new; what is new is that the guide describes it, states the two caveats that apply to it, and quantifies the one that bites: analysis runs inline on your event loop and blocks it for the duration. The guide publishes a table — 0.14 ms at 0 captured queries, 6.5 ms at 100, 32.0 ms at 500 — alongside the machine, the Django version, the analyzer count, the.queryignorerule count and the full workload composition, because every one of those changes the answer: the grouping analyzers are O(distinct fingerprints) rather than O(queries), and per-query cost is dominated by how much SQL each analyzer parses. A narrow-SELECTworkload costs 3.2x to 3.5x less than a wide one at the same count.python -m scripts.bench_analyzeregenerates every published number, including the per-analyzer split behind the claim that one analyzer dominates and a--select-widthflag behind the wide-versus-narrow ratio. Scope is stated rather than implied: the figures cover the analysis stage only —__acall__blocks for analysis and reporting, and reporters run only when the request produced findings — and the 0-query row is the pipeline's floor rather than this route's, because the middleware returns before analysis when nothing was captured while the six other dispatch surfaces do not.
Changed¶
- Settings that were accepted and then ignored now take effect.
STACK_TRACE_EXCLUDEreaches the callsite finder,QUERYIGNORE_PATHselects the.queryignorefile to load, andADMIN_DASHBOARD.max_reportssizes the dashboard buffer. All three were present in the defaults, documented as having no effect, and read by nothing. - An unrecognized
REPORTERSentry now warns instead of silently producing no reporter. A typo and an unsupported name were previously indistinguishable from a working configuration. Suites running-W errorwill fail on such an entry — seeUPGRADING.md. .queryignoreis now honoured on every surface that reports findings, not only the middleware andfix_queries.check_queries,diagnose_project, the pytest plugin, thediagnose_queries()context manager and the Celery integration consolidate onto a singlepipeline.analyze(), so a rule behaves identically everywhere. Suppression stays at the prescription level: captured query counts and timings are never altered, only which findings are reported.sql:rules additionally match the raw SQL behind a finding, not only its description — strictly more suppression. SeeUPGRADING.md.CAPTURE_STACK_TRACESandSTACK_TRACE_EXCLUDEare now read at every interceptor construction site through a sharedbuild_interceptor()factory.CAPTURE_STACK_TRACES: Falsepreviously took effect only in the middleware; the seven other surfaces captured stack traces regardless. Default isTrue, so only users who set itFalseare affected.- A
QUERYIGNORE_PATHthat cannot be resolved warns and falls back to discovery besidemanage.py, rather than being dropped silently. -
diagnose_queries()now emits aQueryDoctorWarningwhen entered from a coroutine. Inside anasync deffunction the block has always reported zero queries however many it issued — it installs itsexecute_wrapperon the entering thread's connection, and Django routes async ORM work to a separate executor thread holding a different one — and it did so silently, so the empty report looked like a clean result. The capture behaviour is unchanged; only the silence is. The predicate is whether an event loop is running on the entering thread, so the two shapes that do capture correctly — adefview served under ASGI, and async_to_async-wrapped helper — do not warn. Use the middleware to diagnose async views. Suites running-W errorwill fail on such a block — seeUPGRADING.md. -
The distribution metadata and the runtime
__version__can no longer disagree. The version was previously declared independently inpyproject.tomlandsrc/query_doctor/__init__.py, with a third copy pinned in a test, and nothing derived any one from the others; the module is now the single source and the suite fails if the installed distribution reports anything else. Note for contributors: bumping the version requirespip install -e "."before the suite passes, because distribution metadata is snapshotted at install time.
Removed¶
IGNORE_PATTERNSfrom the default configuration. No code path ever read it;.queryignoreis the supported way to suppress findings. Leaving the key in your settings is harmless — unknown keys are merged and ignored.- The dead admin-dashboard project-scan integration:
record_project_report, the_latest_project_reportglobal, and the unusedproject_reporttemplate context key. The feature never functioned in any release — nothing wrote the global and the dashboard template never rendered it. See the[1.0.0]historical note. ignore.should_ignore_query, which had no caller. Its goal —sql:rules matching raw SQL — is now delivered byfilter_prescriptionsat the prescription level.- Three exception classes that were never raised anywhere in the package:
ConfigError,AnalyzerErrorandInterceptorError. They were not exported fromquery_doctorand no code path constructed them, but they were published API:docs/api/reference.mdautodocs the wholequery_doctor.exceptionsmodule, so all three rendered on the API reference page. If you catch them by name, import them from your own module or catchQueryDoctorErrorinstead — the base class, which every remaining package exception still inherits from. turbo.patch.set_thread_override, a deprecated shim that delegated toturbo.context.set_turbo_override. It had no caller in the package, no test, and no mention in the docs. Useset_turbo_overridedirectly.
Fixed¶
- Prescriptions no longer point at a line inside Django on Debian and Ubuntu
system Python. Callsite detection named only three Django ORM modules in its
exclude list, so
django/db/models/manager.py(reached by every.objects.create()) anddjango/db/models/base.py(every.save()) were skipped only because they happen to live under a path containingsite-packages. Distributions that install todist-packagesinstead got afile:lineinside Django for those queries, making the prescription unactionable — andfix_querieswould have targeted a Django source file. The wholedjango/dbpackage is now excluded by name, anddist-packagesis recognised alongsidesite-packages. If you setSTACK_TRACE_EXCLUDEto work around this, that entry is now redundant but still harmless. - The Rich console reporter now selects box-drawing characters from the encoding of the stream it writes to, not from stdout. Previously, output aimed at a terminal whose encoding differed from stdout's could contain characters the destination could not encode, garbling the report (or raising on a strict stream). It now renders a plain-ASCII box whenever the destination cannot encode the Unicode one.
docs/deep-dive/comparison.mdno longer asserts that Django's fetch modes are "unreleased as of 2026-07-14". That parenthetical would have become false when Django 6.1 reaches final release, with no code change and nobody touching the file; the linked release notes now carry the status instead. The dated disclaimers atcomparison.md:5andfaq.md:131are deliberately unchanged — those record when a comparison was made and stay true permanently.discover_analyzers()no longer rescans installed entry points on every call. It walked every installed distribution and read itsentry_points.txtfrom disk each time analysis ran — measured at 87 reads per call against 87 installed distributions, roughly 8 ms of synchronous filesystem I/O. The cost was flat in query count, so a request issuing no queries paid the same as one issuing a hundred, and it was paid by every surface: the middleware,diagnose_queries(), the pytest plugin, the Celery integration and all three management commands.diagnose_projectpaid it once per URL pattern. The scan is now cached for the process, taking a zero-querypipeline.analyze()from 7.86 ms to 0.30 ms in the same environment.discover_analyzers()still returns a freshlist, so callers may mutate the result as before; a newdiscover_analyzers.cache_clear()forces a rescan, which any test patching discovery must call.docs/guides/async-support.mdno longer claims that Django's async ORM methods are captured without saying on which route.aget,acreate,acount,aexistsand async iteration are captured through theMIDDLEWAREchain — now measured for all five rather than argued from the mechanism — and capture nothing when the middleware is embedded by hand around an async handler, because__acall__installs its wrapper on the event loop thread's connection while those methods run on an executor thread holding a different one. The section now states the route and carries the counter-case; the claim is qualified, not withdrawn.
[2.1.2] - 2026-07-22¶
Changed¶
QueryDoctorMiddleware.async_capableis nowFalse(wasTrue). This is the fix for the two ASGI defects below, not a withdrawal of ASGI support — ASGI capture works for the first time in this release. Django adapts sync-only middleware withsync_to_async(thread_sensitive=True), which runs it in the same thread-sensitive executor Django runs ORM work in; because database connections are thread-local, that co-location is what lets the interceptor see the queries. Request concurrency is unaffected: Django opens a thread-sensitive context per request, so requests do not serialise. One consequence worth knowing: Django assigns middleware modes from the inside out (django/core/handlers/base.py,load_middleware), so every middleware listed beforeQueryDoctorMiddlewareinMIDDLEWAREnow runs in sync mode as well. With the recommended last position, that is the whole chain. This is standard Django behaviour for any sync-only middleware — a great deal of third-party middleware is sync-only — and it does not affect request concurrency, but async-capable middleware in your stack will run synchronously while query-doctor is installed. Note this is not a change relative to 2.1.1: the missing coroutine marker described below already forced those middleware into sync mode, while also breaking them.async_capableis a public class attribute — if you subclassQueryDoctorMiddlewareand re-declare it asTrue, remove that override. Theasync_capable = Falsesubclass workaround circulating in issue #11 becomes redundant but stays harmless.
Fixed¶
- ASGI requests failed with
TypeError: object HttpResponse can't be used in 'await' expression(HttpResponseServerErrorwhenDEBUG = False), raised atdjango/core/handlers/base.pyinget_response_async. The middleware declaredasync_capable = Truewithout marking its instance as a coroutine function, so Django recorded the handler as async whileconvert_exception_to_responsewrapped it synchronously. Every middleware listed before it then degraded to sync mode and was handed an un-awaited coroutine. Three of Django's sevenstartprojectdefaults —SecurityMiddleware,CommonMiddleware,XFrameOptionsMiddleware— touch the response object unconditionally and raised on it, so any stack built from those defaults with query-doctor anywhere but first position failed on every request. Reported in #11 under Daphne + Channels. (SessionMiddleware,CsrfViewMiddleware, andAuthenticationMiddlewarepass the object through untouched on an ordinary GET, so some stacks returned 200 — and hit the next defect instead.) - No queries were captured under ASGI at all, in any middleware
configuration that did not crash, in every release that shipped the
middleware. The middleware ran on the event loop thread while Django ran all
ORM work — from
async defviews and sync views alike — in a thread-sensitive executor thread. Database connections are thread-local, so theexecute_wrapperwas installed on a connection object the queries never touched, and every ASGI report was silently empty. A 200 response was not evidence the tool had run. - Docs:
docs/guides/async-support.mdrecommendedwith diagnose_queries():insideasync defviews as an alternative to the middleware. Measured under a real ASGI handler, that block reports zero queries — same thread-locality cause as the middleware defect, applied to the context manager. The recommendation has been removed and the limitation documented. No code change; the fix is tracked for a future release. - The async predicate now comes from
asgiref.syncrather thaninspect, which is the predicate Django itself uses.inspect.iscoroutinefunctiondoes not recognise asgiref-wrapped callables before Python 3.12 (inspect.markcoroutinefunctionarrived in 3.12), so on Python 3.10 and 3.11 aQueryDoctorMiddlewareconstructed directly around async_to_asynchandler took the sync path and ran its analysis stage before the view body, producing an always-empty report. Not reachable through Django's middleware chain —load_middlewarenever hands async_capablemiddleware an asgiref-wrapped handler — so this affected direct instantiation only.
[2.1.1] - 2026-07-17¶
Added¶
QueryDoctorWarning(subclass ofUserWarning), exported fromquery_doctor— the package's warning category for runtime advisories, filterable by category (ignore::query_doctor.QueryDoctorWarning) without touching otherUserWarnings.
Changed¶
- The
query_doctorpytest fixture now emits aQueryDoctorWarningwhen requested: itsDiagnosisReportis populated only during test teardown, so assertions on it inside the test body pass vacuously. Use thediagnose_queries()context manager for in-test assertions. Suites running-W error(orfilterwarnings = error) will start failing on fixture use — that is the intended signal; suppress just this category withignore::query_doctor.QueryDoctorWarning.
Fixed¶
- The CI integration guide prescribed in-test assertions on the
query_doctorfixture object and claimed they gate CI — those assertions pass vacuously (see the fixture warning above; users who copied that sample are exactly who the warning fires on). The sample was removed in favour of the pytest guide'sdiagnose_queries()patterns, which do fail the test when violated.
[2.1.0] - 2026-07-16¶
PyPI note: 2.1.0 is the first release published to PyPI since 2.0.0. The
[2.0.1]and[1.0.3]entries below describe versions that were merged and tagged in this repository but never uploaded to PyPI (PyPI has only 1.0.0, 1.0.1, 1.0.2, and 2.0.0). If you are upgrading from 2.0.0, this release is therefore your first with the 2.0.1fix_queries --applycorruption fix. If you ever ran--applyon 2.0.0, follow the damage detection steps inUPGRADING.md("If you ran fix_queries --apply on 2.0.0") before trusting that source.
Upgrading to 2.1.0¶
nplusone, duplicate, and missing_index now respect their
ANALYZERS.<name>.enabled config setting, and every dispatch path
(middleware, pytest plugin, Celery integration, context manager,
check_queries/diagnose_project) now runs the full set of discovered
analyzers instead of a hardcoded subset. If you use check_queries
--baseline, regenerate your baseline after upgrading — the widened
analyzer coverage means an old baseline will report newly-covered findings as
regressions until it's refreshed. Comparing against a baseline saved with a
different query-doctor version now prints a non-blocking warning rather than
failing the check. See UPGRADING.md for the full 2.1.0 upgrade checklist.
Added¶
IssueType.SERIALIZER_METHOD_FIELD— findings fromSerializerMethodAnalyzer(thecheck_serializersstatic analyzer) now carry their own issue type instead of sharingIssueType.DRF_SERIALIZERwith the deleted runtime analyzer.DRF_SERIALIZERremains in the enum for plugin/fixer compatibility.
Fixed¶
nplusone,duplicate, andmissing_indexanalyzers now respect theirANALYZERS.<name>.enabledconfig setting. Previously, disabling these three analyzers had no effect outsidefix_queries— they still ran and reported issues through the middleware, pytest plugin, Celery integration, context manager, andcheck_queries/diagnose_projectcommands.- Middleware, context manager,
check_queries, Celery integration, and the pytest plugin now dispatch throughdiscover_analyzers()instead of five separate hardcoded, inconsistent analyzer lists (3-5 of the built-ins each). Every analyzer's ownis_enabled()gate (above) is what keeps config toggles honored now that dispatch is no longer hand-filtered per site. serializer_methodnow has aDEFAULT_CONFIGentry, soANALYZERS.serializer_method.enabled = Falseactually disables it. Previously there was no config key to set, so the analyzer always ran.fat_select's column-count threshold config key was renamed fromANALYZERS.fat_select.field_count_threshold(the key 2.0.x read) toANALYZERS.fat_select.threshold, matching the other analyzers. The old key is now silently ignored — if you setfield_count_thresholdin your settings, rename it tothresholdwhen upgrading.fix_queries --issue-typenow validates against the five fixer-backed issue types instead of silently accepting any string and producing zero fixes on a typo.check_queries --baselinenow tracks the query-doctor version the baseline was saved with (previously hardcoded to a stale"2.0.0"literal) and prints a non-blocking warning — not a failure — when comparing against a baseline saved with a different version.
Removed¶
- Removed
DRFSerializerAnalyzer, a builtin analyzer that always returned no results through any code path reachable fromfix_queries, the middleware, or any management command. Nothing detectable was lost — not because another analyzer took the work over, but because this one emitted nothing in any reachable path (see the [1.0.0] historical note, and thefix_queriesentry below:drf_serializeris never emitted by the runtime pipeline). The built-in analyzer count is now 7.
The static SerializerMethodAnalyzer (check_serializers command) is
not a replacement for it — the two target different DRF N+1 patterns.
SerializerMethodAnalyzer reads SerializerMethodField declarations and
parses the bodies of the matching get_<field> methods; it inspects nothing
else. DRFSerializerAnalyzer aimed at nested serializer fields whose view
queryset lacked select_related/prefetch_related — a nested
AuthorSerializer() is not a SerializerMethodField, so check_serializers
never looks at it. That pattern is currently uncovered. It was uncovered
before this removal too, since the analyzer never fired.
[2.0.1] - 2026-07-13¶
Never published to PyPI. This version was merged and tagged in the repository but not uploaded; PyPI's latest remained 2.0.0. Its changes — including the
fix_queries --applycorruption fix below — first reach PyPI in 2.1.0. To check whether a 2.0.0--applyrun already damaged your source, seeUPGRADING.md.
Changed¶
docs/getting-started/configuration.md: full rewrite. The previous example used dotted class paths forANALYZERSand dotted-pathREPORTERS, neither of which the code accepts; documented fictional keys (MIN_SEVERITY,QUERY_DOCTOR_ENABLED,EXCLUDE_PATHS,JSON_OUTPUT_DIR/HTML_OUTPUT_DIR); and impliedHTMLReporterworks viaREPORTERS, which it doesn't. Rewritten against the realDEFAULT_CONFIGand each key's call site, including three keys (STACK_TRACE_EXCLUDE,IGNORE_PATTERNS,QUERYIGNORE_PATH) that exist in defaults but aren't read by any code path yet.docs/guides/auto-fix.md: updated to describe the new safe/unsafe split and theast.parse()validation floor.
Fixed¶
fix_queries --applycould write broken code into your source files. The fixer edits the query's callsite line, but forn_plus_oneandfat_selectprescriptions that's frequently the in-loop attribute-access line, not the queryset definition — appending.select_related(...)or.only(...)there produced invalid or silently-wrong Python. This shipped in 2.0.0. If you ranfix_queries --applyon 2.0.0, check your diffs (git diffor the.bakfiles it created) for corrupted lines before trusting them.
As of 2.0.1, --apply only writes fixes for issue types verified safe
(queryset_eval, duplicate_query, missing_index) via a fixed
allowlist (fixer.AUTO_APPLIABLE_ISSUE_TYPES). n_plus_one, fat_select,
and drf_serializer are shown in the diff tagged [MANUAL FIX ONLY] and
refused at write time — apply those by hand. Before writing anything, the
candidate file content is also validated with ast.parse(); a fix that
would produce syntactically invalid Python is rejected instead of written
(this catches syntax errors only, not semantic correctness). fix_queries
--apply now exits nonzero if any fixes were skipped as unsafe or failed
validation, even when other fixes in the same run succeeded.
Post-patch, --apply performs exactly one real code transform
(queryset_eval) plus two # TODO-comment insertions (duplicate_query,
missing_index). n_plus_one and fat_select are dry-run only.
drf_serializer is never emitted by the runtime pipeline fix_queries
uses, so it never reaches the fixer at all.
[2.0.0] - 2026-03-21¶
Added¶
- QueryTurbo: SQL compilation cache with three-phase trust lifecycle
(UNTRUSTED → TRUSTED → POISONED). On cache miss, compiles and caches.
On untrusted hit, validates cached SQL against fresh
as_sql()output and promotes to TRUSTED afterVALIDATION_THRESHOLD(default 3) successful validations. On trusted hit, skipsas_sql()entirely and extracts params directly from the Query tree viaturbo/params.py. On mismatch, poisons the entry for the lifetime of the process (persists across cache clears triggered by migrations). - True SQL Compilation Skipping:
turbo/params.pyextracts params from the Django Query tree without callingas_sql(). Useslookup.as_sql(compiler, connection)per WHERE node for exact param transformations (handles__containswrapping,__isnulldiscarding, etc.) at a fraction of the cost of full SQL compilation. - Prepared Statement Bridge: Multi-database prepared statement support. Automatic protocol-level preparation on PostgreSQL + psycopg3 after a configurable hit-count threshold. Oracle implicit cursor caching. Graceful fallback (TypeError → permanent disable) on unsupported backends.
- AST SerializerMethodField Analyzer: Static analysis of DRF
get_<field>methods usingast.parse()to detect hidden N+1 queries at serialization time. Detects four patterns: related manager access, Model.objects calls, deep attribute chains, and for-loop queryset iteration. - Per-File Analysis:
--fileand--moduleflags oncheck_queriesanddiagnose_projectcommands for focused diagnosis via substring matching. - Benchmark Dashboard:
query_doctor_reportmanagement command generates standalone HTML report with Chart.js graphs showing cache hit rates, top optimized queries, and prepared statement statistics. - GitHub Actions CI Integration:
ci.githubmodule withformat_github_annotations()for inline PR diff annotations,generate_pr_comment()for Markdown PR summaries, andwrite_json_report()for CI consumption. Example workflow inexamples/github-actions/query-doctor.yml. - Baseline Snapshots:
baseline.pywithBaselineSnapshotclass for saving/loading issue snapshots. SHA-256 hashing ignores line numbers for stable identity across code movement.--save-baseline,--baseline, and--fail-on-regressionflags oncheck_queriesanddiagnose_project. - Smart Prescription Grouping:
grouping.pywithgroup_prescriptions()supportingfile_analyzer,root_cause, andviewstrategies.--groupflag oncheck_queriesanddiagnose_project. Console reporter supports grouped output mode. - Async-Safe Context Managers:
turbo_enabled()/turbo_disabled()now usecontextvars.ContextVarinstead ofthreading.local(), making them safe for ASGI deployments with concurrent coroutines. check_serializerscommand: Dedicated management command for AST-based DRF serializer analysis with--app,--file,--format, and--fail-onflags.- Post-migrate cache invalidation: Automatic cache clear on Django
post_migratesignal to prevent stale SQL after schema changes. - Fingerprint collision detection: Cache hit path validates SQL matches and poisons mismatched entries permanently.
__inlookup length in fingerprint: Different__inlist sizes produce different fingerprints, preventing SQL/param count mismatch.select_for_updatein fingerprint: Queries withFOR UPDATE,NOWAIT, andSKIP LOCKEDproduce distinct fingerprints.- Annotation source field fingerprinting: Annotations with the same name but different field targets produce different fingerprints.
Changed¶
- Minimum Python version remains 3.10
- All existing v1.x APIs remain backward compatible
- Version bumped to 2.0.0
- Context managers switched from
threading.local()tocontextvars.ContextVar - Cache entries now track
validated_count,trusted,poisonedstate - New config key:
VALIDATION_THRESHOLD(default 3) controls trust promotion
[1.0.3] - 2026-03-18¶
Never published to PyPI. This version exists only in the repository history; its changes first shipped to PyPI as part of 2.0.0.
Fixed¶
- Missing Index analyzer now recommends
Meta.indexeswithmodels.Index()instead ofdb_index=True, following Django's official recommendation since 4.2 (fixes #1) - Auto-fix for missing indexes now generates
Meta.indexessuggestion instead ofdb_index=True _field_is_indexednow checksMeta.constraintsforUniqueConstraint(modern Django 4.2+ pattern) in addition tounique_together
Changed¶
- Full audit of all prescription texts across all 7 analyzers to align with Django 4.2–6.0 best practices
- Fat SELECT prescriptions now mention
.values()/.values_list()as alternatives when model instances aren't needed - N+1 prescriptions for
prefetch_relatednow mentionPrefetch()objects for advanced filtering scenarios - QuerySet evaluation prescriptions now mention
.iterator()for large querysets to reduce memory usage - Updated docs, README, and all affected tests to reflect new recommendation text
[1.0.2] - 2026-03-16¶
Fixed¶
- Fixed SVG terminal renders not displaying on GitHub (switched to absolute URLs)
- Removed Google Fonts @import from SVGs blocked by GitHub CSP
[1.0.1] - 2026-03-15¶
Changed¶
- Added SVG terminal renders to README for visual feature showcase
- Added Django 6.0 mention in README requirements
[1.0.0] - 2026-03-13¶
Historical note (added during the 2.1.0 remediation): two features listed below never functioned in any release. The runtime "DRF Serializer N+1" analyzer returned no results through any reachable code path and was removed in 2.1.0 (see the [2.1.0] "Removed" entry; static DRF analysis via
check_serializersreplaces it). "Admin dashboard integration showing latest project scan results" never activated:record_project_reporthas no caller in any released version and the dashboard template does not render project-report data; that dead code — the function, its global, and the unused context key — was removed in 2.2.0. The original entries are preserved unchanged below.
Added¶
Core Pipeline¶
- Query interception via
connection.execute_wrapper()— works withoutDEBUG=True - SQL fingerprinting with normalization and SHA-256 hashing
- Source code mapping with file:line references via stack trace analysis
- Django middleware with zero-config setup (one line in
MIDDLEWARE) diagnose_queries()context manager for targeted analysis@diagnoseand@query_budgetdecorators- Full configuration system via
QUERY_DOCTORDjango settings
Analyzers¶
- N+1 Detection — fingerprint-based grouping with FK pattern matching
- Duplicate Query Detection — exact-duplicate identification (same SQL and parameters, hashed and grouped)
- Missing Index Detection — WHERE/ORDER BY columns without indexes
- Fat SELECT Detection — flags
SELECT *when fewer columns suffice - QuerySet Evaluation — suggests
.count(),.exists(),.first()alternatives - DRF Serializer N+1 — detects missing prefetch in DRF views
Reporters¶
- Console — Rich terminal output with fallback to plain text
- JSON — structured output for CI/CD pipelines
- Log — Python logging integration
- HTML — standalone dashboard report
- OpenTelemetry — span and event export for observability stacks
Ecosystem¶
- Celery task support via
@diagnose_taskdecorator - Async Django/ASGI middleware support
- Custom analyzer plugin API via Python entry points
- Pytest plugin with
query_doctorfixture check_queriesmanagement command for CI analysisquery_budgetmanagement command for budget enforcement
Project-Wide Diagnosis¶
- diagnose_project management command — crawls all project URLs and generates app-wise health report
- Standalone HTML report with health scores, sortable app scoreboard, and per-URL prescription detail
- JSON report output for CI integration
- Admin dashboard integration showing latest project scan results
Auto-Fix & CI¶
- Auto-Fix Mode —
fix_queriesmanagement command applies diagnosed fixes with dry-run default and .bak backups - Diff-Aware CI —
--diffflag forcheck_queriesto analyze only files changed vs a git ref - .queryignore — project-level file to suppress known false positives by SQL pattern, file, callsite, or issue type
Monitoring¶
- Admin Dashboard — staff-only in-memory dashboard showing recent query diagnosis reports
- Query Complexity Scorer — regex-based SQL complexity analysis flagging excessive JOINs, subqueries, and OR chains
Developer Experience¶
- Every prescription includes severity, description, file:line, and exact code fix
- Zero required dependencies beyond Django
- Optional extras: Rich, Celery, OpenTelemetry
- Full type annotations with
py.typed(PEP 561) - CI matrix: Python 3.10-3.13 x Django 4.2-6.0