Skip to main content

SQL injection in Python: Django raw(), cursor.execute and SQLAlchemy text() done right

Python ORMs make SQL injection rare, which is exactly why it survives. Django querysets and SQLAlchemy's expression language parameterize every value for you, so a team can go months without writing a query by hand. Then someone needs a window function, a report, or a quick fix in production, reaches for raw SQL, and builds the string with an f-string because that is how every other string in the codebase is built.

This post walks through the places Python SQL injection actually appears (Django raw(), extra(), RawSQL, DB-API cursor.execute, SQLAlchemy text()), what parameterization looks like in each, and the one case parameters cannot cover.

Why the ORM does not protect raw SQL

Django's security guide puts it plainly: querysets are safe because "a query's SQL code is defined separately from the query's parameters", and the database driver handles the parameters. The same page says raw queries and custom SQL "should be used sparingly" and that you should be careful with extra() and RawSQL (Django security docs).

The protection lives in the separation. The moment user input is pasted into the SQL text itself, the driver has nothing to separate, and the ORM cannot help.

Django raw(): the quoted placeholder trap

The vulnerable shape is the obvious one:

# Vulnerable: the name becomes part of the SQL text
def search(request):
    name = request.GET["name"]
    people = Person.objects.raw(f"SELECT * FROM app_person WHERE last_name = '{name}'")

A request with name=x' OR '1'='1 returns every row. The fix is the params argument with unquoted %s placeholders:

# Fixed: the driver sends the value separately
people = Person.objects.raw(
    "SELECT * FROM app_person WHERE last_name = %s", [name]
)

Two near misses are just as unsafe, and Django's raw SQL guide calls out both: using % string interpolation on the query, and putting quotes around the placeholder ('%s'). Its words: "Do not use string formatting on raw queries or quote placeholders in your SQL strings!" (Performing raw SQL queries). A %s that you format yourself is just a fancier f-string.

extra() and RawSQL

QuerySet.extra() is documented as a last resort: "an old API that we aim to deprecate at some point in the future", with no further bug fixes. It accepts params and select_params, and the docs warn that every user-controlled value must go through them. The classic mistake is building a where clause from input:

# Vulnerable
qs = Entry.objects.extra(where=[f"headline = '{q}'"])

# Fixed, and better still: rewrite as a normal filter
qs = Entry.objects.extra(where=["headline = %s"], params=[q])
qs = Entry.objects.filter(headline=q)

RawSQL is the expression form of the same idea, usually inside annotate(). It takes a SQL fragment and a params tuple, and the rule is identical: the fragment is code, the tuple is data.

from django.db.models.expressions import RawSQL

# Vulnerable
qs = Order.objects.annotate(score=RawSQL(f"rank_for(id, '{term}')", ()))

# Fixed
qs = Order.objects.annotate(score=RawSQL("rank_for(id, %s)", (term,)))

Before reaching for either, check whether F(), Func, Window or a database function in django.db.models.functions already expresses the query. Most reasons for extra() disappeared years ago.

cursor.execute and the DB-API

Below the ORM, Django's connection.cursor() and plain drivers such as psycopg or sqlite3 follow the Python DB-API: the first argument is SQL, the second is a sequence or mapping of parameters.

# Vulnerable: f-string, .format() and % all build the SQL text
cursor.execute(f"UPDATE accounts SET email = '{email}' WHERE id = {user_id}")
cursor.execute("DELETE FROM sessions WHERE token = '%s'" % token)

# Fixed
cursor.execute("UPDATE accounts SET email = %s WHERE id = %s", [email, user_id])
cursor.execute("DELETE FROM sessions WHERE token = %s", [token])

The difference between the second vulnerable line and the fix is one character: a % operator versus a comma. That is why code review misses it and why it is worth a mechanical check.

The placeholder style depends on the driver. Django's cursor wants %s even on SQLite, while the raw sqlite3 module uses ? or :name. Mixing them up gives an error, not an injection, so the failure mode here is loud.

SQLAlchemy text()

SQLAlchemy 2.x requires textual SQL to be wrapped in text(), which tempts people into text(f"..."). text() supports named bind parameters with a colon:

from sqlalchemy import text

# Vulnerable
rows = session.execute(text(f"SELECT * FROM users WHERE email = '{email}'"))

# Fixed: :email is a bind parameter
rows = session.execute(
    text("SELECT * FROM users WHERE email = :email"), {"email": email}
)

If you can, use the expression language instead (select(User).where(User.email == email)). It parameterizes for you and keeps the query composable.

The part parameters cannot do: identifiers

Parameters carry values. They cannot carry a table name, a column name, a sort direction or a keyword like ASC. This is where careful teams still get caught:

# Vulnerable: a sort column from the query string
order = request.GET.get("sort", "created_at")
cursor.execute(f"SELECT id, title FROM posts ORDER BY {order}")

Map the input to a fixed set of known identifiers instead of passing it through:

SORTS = {"new": "created_at DESC", "old": "created_at ASC", "title": "title ASC"}
order_sql = SORTS.get(request.GET.get("sort"), "created_at DESC")
cursor.execute(f"SELECT id, title FROM posts ORDER BY {order_sql}")

The f-string is now harmless because every possible value comes from your dictionary. With psycopg you can also compose identifiers safely through its sql.Identifier helper, and in Django the ORM's order_by() with a validated field name does the same job. The OWASP SQL Injection Prevention Cheat Sheet recommends exactly this allowlist approach for the parts of a query that cannot be bound.

A short checklist

  • Search for raw(, extra(, RawSQL(, .execute( and text( and read every hit that builds its first argument dynamically.
  • Treat f-strings, .format(), % and + in a SQL argument as a defect unless every piece is a constant or from an allowlist.
  • Never quote a placeholder.
  • Allowlist identifiers, sort orders and LIMIT values; cast numeric input with int() as a second layer, not the only one.
  • Give the application's database user only the privileges it needs, so an injection that slips through reads less.

What Vulkro checks

Vulkro Core traces request data in Python handlers (Flask request.*, Django request.GET / request.POST, FastAPI request bodies and handler parameters) to SQL execution sinks such as cursor.execute, engine.execute and session.execute that run without parameter binding or an allowlist. The rule is PY-TAINT-SQL-001, reported as CWE-89, and each finding carries the hop chain from the request to the call, so you can see which variable carried the input and where. A value that only passes through a dictionary lookup like SORTS above is the shape to fix toward. Coverage details are on the Python docs page, and vulkro explain PY-TAINT-SQL-001 prints the guidance in your terminal.

A finding with a full trace is Proven; see Proven, Unproven and not checked for what the other states mean.

FAQ

Are f-strings in SQL always SQL injection?

Not always, but treat them as a defect. An f-string is only safe when every interpolated piece is a constant or comes from a fixed allowlist in your code. If any piece comes from a request, a file or the database, use parameters.

Is Django raw() safe from SQL injection?

Yes, when you pass values through its params argument with unquoted %s placeholders. It is unsafe when you format the query string yourself or put quotes around the placeholder.

How do I parameterize a table or column name in Python?

You cannot bind identifiers as parameters. Map user input to a fixed set of known names in your code, or use your driver's identifier composition helper, and never pass the raw input into the SQL text.

Want to see it on your own code? Start a 14-day trial. For the theory behind the trace, read taint analysis: sources, sinks and sanitizers.