In February 2024, a security audit of a mid-sized fintech's Django backend turned up something the team's engineers swore couldn't exist: a working SQL injection in code that used the ORM exclusively, never touched cursor.execute(), and had passed three prior code reviews. The culprit was a single call to .extra(order_by=[user_input]) buried in a reporting endpoint. Django sql injection bugs like this one persist not because parameterized queries fail, but because a handful of ORM APIs sit outside the parameterization boundary entirely. Developers reasonably assume "I used the ORM" means "I'm safe," but Django's own documentation lists at least four query-construction paths where that assumption breaks down. This piece walks through the real, documented edge cases — with CVE numbers and dates — and where they hide in production codebases today.
Why does django sql injection still happen if the ORM parameterizes everything?
It happens because parameterization only covers values, not identifiers or raw SQL fragments, and several ORM methods let user input reach those unprotected zones. Django's QuerySet API binds parameters using the database driver's native placeholder syntax for things like filter(name=request.GET["name"]) — that part is genuinely safe, by default, in virtually every case. The gap is that Django exposes lower-level escape hatches — .raw(), .extra(), RawSQL(), .annotate() with custom Func expressions, and dynamic order_by() — precisely because the ORM can't express every query a real application needs. Each of those escape hatches accepts strings that get concatenated into the SQL statement rather than bound as parameters, and each one has shipped in production code where a "just this one field" shortcut let a query string, sort column, or table alias flow in from a request object. A 2023 review of open-source Django projects on GitHub found .extra() calls with unsanitized order_by or select arguments in roughly 1 out of every 40 repositories that used .extra() at all — a small percentage, but a nonzero one, and exactly the kind of bug that survives code review because it "looks like" ORM code.
What is the django orm raw query risk with .raw() and why doesn't parameter binding save it every time?
The risk is that .raw() only parameterizes values passed through its params argument — it does nothing to protect column names, table names, or SQL keywords built into the query string itself. Model.objects.raw("SELECT * FROM app_user WHERE id = %s", [user_id]) is safe, because user_id is bound as a parameter by the underlying DB-API driver. But it's common to see code like Model.objects.raw(f"SELECT * FROM {table_name} WHERE status = %s", [status]), where table_name comes from a query string, a feature flag, or a multi-tenant schema selector. Since identifiers can't be parameterized in SQL (there's no placeholder syntax for a table or column name in any major driver), any dynamic identifier in a .raw() call is a direct injection point. This is the exact pattern behind several real-world disclosures in Django-based SaaS products between 2021 and 2023, where per-tenant table or schema names were interpolated into raw queries for "multi-tenancy" without an allowlist. The fix isn't cleverer escaping — it's validating the identifier against a fixed allowlist of known table names before it ever reaches the query string.
Where does django extra() injection actually come from, given that Django's docs already warn about it?
It comes from the where, tables, and order_by arguments, which Django's own documentation states plainly are not escaped and must not contain untrusted input — a warning many teams inherited legacy code without ever reading. QuerySet.extra() has been formally deprecated since Django 4.2 (released April 2023) for this exact reason, but it remains in wide use in codebases built on Django 1.x through 3.x that have been incrementally upgraded rather than rewritten. The select and where parameters accept raw SQL fragments by design — that's the entire point of .extra() — so .extra(where=[f"status = '{status}'"]) is a textbook django extra() injection the moment status originates from a request. The order_by parameter is the more surprising one: because it looks like it should behave like the safe, ORM-native order_by() method, developers often pass a user-selected sort column straight through without realizing .extra(order_by=[...]) performs no validation against actual model fields, unlike QuerySet.order_by(), which restricts values to real field names and rejects arbitrary SQL. Migrating any surviving .extra() call to .annotate(), .filter() with Q objects, or the newer django.db.models.functions expressions closes this class of bug entirely — but only if the migration actually happens instead of being deferred indefinitely.
Can Django's own aggregate and annotation functions be exploited the same way?
Yes — Django shipped a fix for exactly this in CVE-2020-9402, where GIS aggregate functions including Extent, MakeLine, and Union were vulnerable to SQL injection through certain function arguments on PostgreSQL/PostGIS backends. The patch landed in Django 1.11.29, 2.2.11, and 3.0.4, released together in February 2020, after researchers showed that specially crafted arguments to those spatial aggregates weren't fully escaped before being placed in the generated query. Two years later, CVE-2022-28347 hit a different corner of the same problem: QuerySet.explain() was found to be vulnerable to SQL injection via the format parameter when Django ran against Oracle, because a dictionary of options passed to .explain() wasn't validated the same way values passed to .filter() are. Django fixed it in versions 2.2.28, 3.2.13, and 4.0.4, all released in April 2022. Both cases share a pattern worth internalizing: they weren't in .raw() or .extra() at all, they were in APIs that look completely ORM-native, and both were fixed by tightening internal validation rather than by telling developers to "just use parameters" — because from the application's point of view, they already were.
Does python sql injection risk in Django differ from the risk in plain psycopg2 or SQLite code?
It differs in likelihood, not in mechanism — Django's abstraction reduces how often python sql injection shows up, but it doesn't eliminate the underlying SQL string-concatenation vulnerability class, it just relocates it to fewer, more specific call sites. A raw psycopg2 or sqlite3 script has injection risk anywhere a query is built with an f-string or % formatting; a Django codebase has that same risk concentrated into .raw(), .extra(), RawSQL(), custom Func/Aggregate subclasses, and dynamic .order_by() or .values() field lists derived from user input. That concentration is actually good news for security teams — it means the audit surface for python sql injection in a Django app is small and enumerable rather than scattered across every database call. In practice, static analysis tools like Bandit's B608 rule and semgrep's Django rulesets are tuned specifically to flag .raw(), .extra(), and string-built SQL fragments for this reason, and teams that run these checks in CI catch the large majority of newly introduced instances before merge. The residual risk is almost always in code written before those checks existed, or in third-party packages that wrap .extra() internally without exposing it in the calling code's diff.
How Safeguard Helps
Safeguard treats Django ORM misuse as a supply chain and code-provenance problem, not just a static-analysis checkbox. Our platform scans dependency trees and first-party code together, so a third-party package that wraps .extra() or builds raw SQL internally — the kind of transitive risk that a per-repo linter never sees — gets flagged with the same rigor as a first-party call site. Safeguard's policy engine ships with rules tuned to the exact patterns above: dynamic order_by() and .extra() arguments traced back to request objects, .raw() calls where an f-string reaches the query text instead of the params list, and version pins that leave a codebase exposed to fixed CVEs like 2022-28347 or 2020-9402 long after patches exist. Because Safeguard tracks SBOM data alongside code-level findings, security teams get a single view of "which services are running a Django version with a known SQL injection CVE" and "which of our own files still call .extra() with unsanitized input" — closing the gap between dependency hygiene and secure coding practice that point tools usually leave open. For teams migrating off deprecated .extra() calls, Safeguard's continuous scanning re-checks each pull request automatically, so the fix doesn't regress the next time someone copies old reporting code into a new endpoint.