A Django application is safe from SQL injection as long as every value reaches the database as a bound parameter and every identifier (a column, a sort key, an alias, a keyword name) is chosen by your code rather than by the client. The four places where application code writes SQL text (raw(), extra(), RawSQL and cursor.execute()) break the first rule when they are fed formatted strings, and a handful of ORM methods break the second when a request field is passed through as a name. This guide covers each one with the rule from Django's own documentation, the review question to ask, a test that catches regressions, and what runtime evidence adds.
The guarantee, and its exact edge
Django's security guide states the protection plainly: "a query's SQL code is defined separately from the query's parameters", and the parameters are escaped by the database driver (SQL injection protection). That guarantee covers values. Entry.objects.filter(headline=term) is safe for any term, because the text reaching the database is ... WHERE headline = %s and the value travels separately.
The edge of the guarantee is everything that is not a value. SQL has no way to bind a table name, a column name, an alias or a keyword, so those are written into the statement text. The guide says so in the same section: parameters cannot protect the SQL itself, and the escape hatches below hand the SQL to you. The whole of this guide is those two edges: text you write, and names the client picks.
Escape hatch 1: cursor.execute()
Executing custom SQL directly is the lowest level. The documented form is cursor.execute(sql, [params]), with %s as the placeholder on every backend, including SQLite, where Django translates it for you.
from django.db import connection
def totals_for(customer_id: int):
with connection.cursor() as cursor:
cursor.execute(
"SELECT status, SUM(amount) FROM orders WHERE customer_id = %s GROUP BY status",
[customer_id],
)
return cursor.fetchall()
Two mistakes recur in review:
- Formatting the value in.
cursor.execute(f"... WHERE customer_id = {customer_id}")orcursor.execute(sql % params)runs the formatting in Python before the driver sees anything. Anintannotation on the argument does not help: nothing enforces it at runtime unless a form or a URL converter did. - Quoting the placeholder. The docs are explicit: "you must not include quotes around the
%splaceholders in the SQL string."'%s'turns a bound parameter into a string the database parses.
Escape hatch 2: Manager.raw()
raw() runs hand-written SQL and maps rows to model instances. It takes params as a list or a dictionary, and the dictionary form uses %(name)s placeholders. The documentation's warning is one line: "Do not use string formatting on raw queries or quote placeholders in your SQL strings!"
from myapp.models import Person
def people_named(last_name: str):
return list(
Person.objects.raw(
"SELECT id, first_name, last_name FROM myapp_person WHERE last_name = %(last)s",
{"last": last_name},
)
)
raw() does not check the SQL at all, so keep such queries in one module whose name signals review, not scattered across views.
Escape hatch 3: QuerySet.extra()
extra() splices literal SQL into SELECT, WHERE, ORDER BY and table lists. Django's reference says it is "an old API that we aim to deprecate at some point in the future" and that the project is "no longer improving or fixing bugs for this method". That makes it the most likely place in an older codebase to find an f-string, because the code around it has not been touched in years.
extra() accepts params for its where and select_params for its select, and those are bound correctly. The injection comes from the clauses themselves. order_by inside extra() has no parameters at all: it is pure text, so a sort key from the request lands straight in the SQL.
from myapp.models import Entry
# Still common in old code: a formatted where clause
def entries_with_tag_old(tag: str):
return Entry.objects.extra(where=["tags LIKE %s"], params=[f"%{tag}%"])
# The ORM form, which binds the value and needs no extra()
def entries_with_tag(tag: str):
return Entry.objects.filter(tags__contains=tag)
The first function is correctly parameterized and still worth replacing: every extra() is a clause nobody is maintaining. Treat each remaining call as debt with an owner.
Escape hatch 4: RawSQL expressions
RawSQL embeds a SQL fragment inside an annotation, a filter or an ordering. Its params argument is required, and the reference explains why: "params is a required argument to force you to acknowledge that you're not interpolating your SQL with user-provided data." The same section warns, again, not to quote the placeholders.
The anti-pattern is satisfying the signature with an empty tuple:
from django.db.models.expressions import RawSQL
def ranked(queryset, tag: str):
# Correct: the value is bound
return queryset.annotate(
score=RawSQL("SELECT score FROM ranking WHERE tag = %s", (tag,))
)
When you review a RawSQL, read the first argument for any {, % formatting or +, and read the second for an empty tuple next to a non-constant first argument. Either is a finding.
The expressions reference has one more trap in the same family. A custom Func has a template and accepts **extra keyword arguments that are interpolated into that template rather than bound. The docs say extra "must not contain untrusted user input", and the same holds for arg_joiner and template themselves. A Func subclass that takes a request value as a template keyword is RawSQL with better manners.
The ORM edge cases: names the client picks
The second edge is subtler, because there is no SQL in the application at all. The ORM must write identifiers into the statement, and some of its methods take identifiers from you as strings or keyword names. When a request field is passed through as one of those, the client is choosing SQL text.
Sort keys. order_by() resolves field names against the model and rejects unknown ones, which is good, but it has not always been complete: CVE-2021-35042 was SQL injection in QuerySet.order_by() "if order_by is untrusted input from a client of a web application". Allowlist the sort key in your own code regardless of the Django version:
SORTS = {"newest": "-created_at", "oldest": "created_at", "title": "title"}
def sorted_entries(queryset, sort: str):
return queryset.order_by(SORTS.get(sort, "-created_at"))
Column aliases. The two severe Django SQL injection CVEs of 2024 and 2025 were both alias injection: CVE-2024-42005 (CVSS 7.3 High per NVD; CISA rates it 9.8 Critical) through a JSON key passed to values() or values_list(), and CVE-2025-57833 (CVSS 8.1 High per NVD) through FilteredRelation names expanded into annotate() or alias(). Both advisories credit Eyal Gabay of EyalSec; Django's security team fixed both promptly, and this guide owes them thanks. The shape to look for in your own code is a request value used as a projection key or a keyword name, and the full account covers why one validator was missing on those paths.
Keyword expansion. CVE-2025-64459 extended the same family to filter(), exclude(), get() and Q() through a crafted _connector key in a dictionary expanded with **. The code pattern all of these reward is the same one:
ALLOWED_FILTERS = {"category", "brand", "in_stock"}
def filtered_products(queryset, params: dict):
# The code chooses the names; the client supplies only values
filters = {k: v for k, v in params.items() if k in ALLOWED_FILTERS}
return queryset.filter(**filters)
Product.objects.filter(**request.GET.dict()) is the one-liner to search for, together with annotate(**, values(* and alias(** fed from a request, a JSON body or a form's cleaned_data keys. Keep Django current for the framework's own fixes, and keep the allowlist for the shape itself, because the class has recurred several times.
A test that catches both edges
Code review is the primary control. A regression test is the backstop, and Django gives you the tool: CaptureQueriesContext records every statement the test executed, with its final SQL text. If a marker string you passed as input ever appears in that text, a value became SQL.
import pytest
from django.db import connection
from django.test.utils import CaptureQueriesContext
MARKER = "zqx_marker_7f3e"
@pytest.mark.django_db
def test_search_binds_the_term(client):
with CaptureQueriesContext(connection) as ctx:
client.get("/search/", {"q": MARKER, "sort": MARKER})
for query in ctx.captured_queries:
assert MARKER not in query["sql"], query["sql"]
On most backends the recorded sql is the statement with parameters substituted for display, so a bound value does appear in it. Run the test against a marker that contains a quote character, zqx'marker, and assert on the unescaped form: a bound parameter shows up escaped, a formatted one shows up raw. For sort keys and filter names the assertion is simpler, because a legitimate identifier never contains your marker at all, so any appearance is a finding. Add one such test per view that accepts a search term, a sort key or a filter dictionary. It costs seconds and it catches the f-string someone adds to a raw() call two years from now.
The second-order case
Everything above assumes the untrusted value arrives in the request that uses it. The harder case is a value stored safely in one request and read back into SQL text in another: a saved "report column" preference, a tag name used later as an extra(order_by=...) key, a tenant's custom field name. The write is parameterized and correct. The read returns a plain string, and the string goes into a query as a name.
The review rule is the same (a stored value is still a client value), but review finds it less often, because the two halves live in different files. This is the case where a runtime view earns its keep.
What runtime evidence adds
Review, Bandit's B608 and a capture test are each worth running. What they share is that they reason about code or about one test's input. es-python, the EyalSec Python runtime, adds the running system's own answer: it follows untrusted data through the program as it runs and checks it at the operation itself, the moment a statement is sent, so it reports a query only when attacker-controlled characters were actually part of the SQL text. A correctly bound parameter never enters that text, so a correctly parameterized query produces no event; a value that became an identifier does. The event is labelled sql raw for raw() and extra() and by the driver's own call for cursor.execute(), carries the sqli impact tag, and shows the value, where it entered and the call path (what the event looks like). With the db_rows source on, values read back from the database are followed too, which is how the second-order case above shows up with the stored string named as the source.
An event is evidence that the flow happened, not proof that it is exploitable; if the code compared the value against a fixed set first, the flow is real and harmless, and the event gives you what you need to say so. Under a Raise rule the statement is stopped before it reaches the database. Pricing is metered per machine and per event; see pricing.
Checklist
| Where | Rule | Review question |
|---|---|---|
cursor.execute() |
%s placeholders, values in the second argument, never quoted |
Is the first argument a constant? |
raw() |
params= list or dict, no formatting |
Same, plus: is the statement read-only? |
extra() |
params= and select_params=; order_by is pure text |
Can this be an ORM expression instead? |
RawSQL, Func |
params holds every value; template and extra hold no input |
Is params an empty tuple next to a non-constant? |
order_by(), values(), annotate(**), filter(**) |
names from an allowlist your code owns | Does any name trace back to a request or a stored string? |
If you want to see which of your Django queries actually receive untrusted data in their text, on your own code, you can book a live demo.