Auto-Fix¶
django-query-doctor can automatically apply the fixes it prescribes by modifying your Python source files. This page explains how the auto-fix system actually works, what it can and cannot do, and how to use it safely.
How It Works¶
When you run fix_queries, django-query-doctor:
- Analyzes the target URL by executing a request and capturing queries.
- Generates prescriptions with exact file paths, line numbers, and a suggested fix as text.
- Reads the single source line at the prescription's
callsite.line_numberand applies a regex substitution on that one line — it does not parse or understand the surrounding code. - On
--apply, only issue types known to be safe are written to disk (see Supported Fix Types); the rest are refused and reported instead.
Each issue type has its own regex-based line handler; some just append a method call to the end of the line, others prepend a # TODO comment. There is no code restructuring or understanding of surrounding context — the handler only ever sees one line.
Known limitation — the edited line may not be the line you expect. The line a fix targets is the callsite of the captured query: the closest application-code stack frame to where the query actually executed (see
stack_tracer.capture_callsite). For the classic N+1 pattern —— the callsite is the
book.author.nameline inside the loop, not theBook.objects.all()line. Appending.select_related('author')there would producename = book.author.name.select_related('author'), which is not valid code. This is exactly whyn_plus_one(andfat_select, for the same reason) are not auto-applied —--applyrefuses to write them and reports them for manual review instead. Only--dry-runshows what the fix would look like.Before writing anything, the fixer also parses the candidate file content with
ast.parse()and refuses to write if that fails — a syntax-error floor. This catches malformed output, not semantic correctness: a fix that lands inside a comment, or after a valid-but-wrong expression, still parses cleanly and would still be written. Treat--apply's output as a starting diff to review, not a guaranteed-correct edit.
Dry Run (Default)¶
By default, fix_queries runs in dry-run mode. It shows you a diff of what would change without modifying any files:
The diff format (from QueryFixer.generate_diff) shows the file, the line number, the issue description, the original line, and the replacement line(s):
--- myapp/views.py
+++ myapp/views.py
@@ -8,1 +8,1 @@
[N+1 detected: 47 queries for table "myapp_author" (field: author)]
- books = Book.objects.all()
+ books = Book.objects.all().select_related('author')
Review this output carefully before proceeding.
Applying Fixes¶
To modify your source files, pass the --apply flag:
Only fixes for queryset_eval, duplicate_query, and missing_index are actually written — see Supported Fix Types for why the other issue types are refused. If any fixes were skipped as unsafe, or rejected because they'd produce invalid Python, fix_queries prints a warning/error for each and exits nonzero (CommandError) even though the safe fixes in the same run were still applied.
Warning: Always ensure your code is committed to version control before running
--apply. This is a line-level regex tool, not a code-aware refactorer — theast.parse()floor only rejects syntactically invalid output, not semantically wrong output. Usegit diffto review changes after applying, and use--no-backuponly if you don't want the automatic.bakfiles it creates alongside each modified file.
Supported Fix Types¶
The fixer dispatches on Prescription.issue_type (fixer.py:_parse_fix). Five of the seven issue types have a fix handler, but --apply only writes three of those five to disk — the rest are shown in the diff (--dry-run or the pre-write diff under --apply), tagged [MANUAL FIX ONLY], and refused at write time:
Issue Type (IssueType.value) |
What the handler does | Handler | Auto-applied by --apply? |
|---|---|---|---|
n_plus_one |
Extracts a .select_related(...) or .prefetch_related(...) call from the fix suggestion text and appends it to the end of the callsite line |
_fix_nplusone |
No — callsite is often mid-loop, not the queryset definition (see the limitation above) |
drf_serializer |
Same handler as n_plus_one — but see the note below, this issue type is never produced by the runtime pipeline that fix_queries uses |
_fix_nplusone |
No (also never emitted here in the first place) |
fat_select |
Extracts a .only(...) or .defer(...) call and appends it to the end of the callsite line |
_fix_fat_select |
No — same callsite-line risk as n_plus_one |
queryset_eval |
Rewrites len(x) → x.count() and/or if x: → if x.exists(): on the callsite line via regex |
_fix_queryset_eval |
Yes — the analyzer only fires when the anti-pattern is on the callsite line itself, so this handler is safe by construction |
duplicate_query |
Prepends a # TODO: Cache this query result to avoid duplicate execution comment above the callsite line — it does not extract a shared variable |
_fix_duplicate |
Yes — comment-only, can't corrupt code |
missing_index |
Prepends a # TODO: Consider adding an index via Meta.indexes — <suggestion> comment above the callsite line — it does not add a models.Index() entry |
_fix_missing_index |
Yes — comment-only, can't corrupt code |
complexity |
No handler. --issue-type complexity will never produce a fix. |
— | — |
The auto-applied set is a fixed allowlist (fixer.AUTO_APPLIABLE_ISSUE_TYPES) — a future issue type only joins it after its handler is independently verified safe, not by default.
meta_index and cache_queryset are not real fix types in the code — they were names used in an earlier draft of this page. The actual behavior for missing-index and duplicate-query issues is a # TODO comment, shown below.
Targeting Specific Fix Types¶
Use --issue-type (not --fix-type, which does not exist) to limit which issues are fixed. It accepts one or more IssueType values as strings, with no validation against the real set — a typo silently produces zero fixes:
# Only apply N+1 fixes
python manage.py fix_queries --url /api/books/ --issue-type n_plus_one --apply
# Only apply duplicate-query TODO comments
python manage.py fix_queries --url /api/books/ --issue-type duplicate_query --apply
# Apply multiple types
python manage.py fix_queries --url /api/books/ \
--issue-type n_plus_one fat_select \
--apply
--issue-type only accepts the five values that actually produce fixes through fix_queries: n_plus_one, duplicate_query, fat_select, queryset_eval, missing_index. complexity is detected but has no fix handler. SerializerMethodField findings are produced by the separate check_serializers static analyzer (see SerializerMethodField Analysis), not by the runtime pipeline fix_queries uses, so it isn't a valid --issue-type value.
Fix Details¶
n_plus_one — dry-run only, never auto-applied¶
Targets N+1 patterns caused by accessing ForeignKey, OneToOne, ManyToMany, or reverse-FK relations repeatedly. The suggested fix appends .select_related('field_name') or .prefetch_related('field_name') to the end of the callsite line — see the limitation above about which line that is.
--dry-run shows what this would look like; --apply refuses to write it and reports it as skipped instead, because the callsite is frequently mid-loop, not the queryset definition:
# Suggested — shown in the diff, never written by --apply
books = Book.objects.all()
# becomes:
books = Book.objects.all().select_related('author')
Apply this one by hand, at the actual queryset definition line.
fat_select — dry-run only, never auto-applied¶
Targets queries that fetch all columns when only a subset is used. The suggested fix appends .only(...) or .defer(...) to the end of the callsite line — same callsite-line risk as n_plus_one, so --apply refuses to write it.
# Suggested — shown in the diff, never written by --apply
books = Book.objects.filter(published=True)
# becomes:
books = Book.objects.filter(published=True).only('id', 'title')
queryset_eval¶
Targets len(qs) and if qs: patterns. The fix rewrites the callsite line directly via regex.
duplicate_query¶
Targets queries executed more than once with identical SQL and parameters. The fix does not extract a shared variable — it prepends a comment for you to act on manually:
# Before
count = Book.objects.filter(active=True).count()
# After
# TODO: Cache this query result to avoid duplicate execution
count = Book.objects.filter(active=True).count()
missing_index¶
Targets model fields used in WHERE/ORDER BY clauses without a database index. The fix does not add a models.Index() entry — it prepends a comment:
# Before
published_date = models.DateField()
# After
# TODO: Consider adding an index via Meta.indexes — Add an index on published_date
published_date = models.DateField()
Adding a real index still requires manually editing Meta.indexes and running makemigrations/migrate.
Best Practices¶
- Always review diffs first. Run without
--apply, read every change, then apply. - Commit before applying. Use version control so you can revert if needed.
- Apply one issue type at a time with
--issue-type. This makes it easier to review and test each change. - Run tests after applying. Ensure your test suite passes after each batch of fixes.
- Apply
n_plus_oneandfat_selectfixes by hand.--applywon't write them for you (see Supported Fix Types) — use the diff as a starting point and place the fix at the actual queryset definition, not the callsite line. - Handle
missing_indexTODOs separately. These require a manualMeta.indexesedit plus a migration; evaluate each one individually. - Check the exit code in CI.
fix_queries --applyexits nonzero if any fixes were skipped as unsafe or failed the syntax-validity check, even if other fixes in the same run succeeded.
Further Reading¶
- Management Commands — Full command reference.
- How It Works — Understanding prescriptions and the analysis pipeline.
- CI Integration — Using auto-fix in CI workflows.