SQL injection in Django, Flask and FastAPI in 2026: the patterns that still ship

SQL injection still ships in Python web backends in 2026, and almost never through the ORM's ordinary filter methods. It comes in through the escape hatches (raw(), extra(), RawSQL, cursor.execute() and SQLAlchemy text()) when a query is built with string formatting, and through identifiers such as column names, sort keys and aliases, which parameter binding cannot protect at all. This article lists the patterns that keep appearing in Django, Flask and FastAPI code, shows the fix for each, and explains how to find them in an existing codebase.

Why an ORM does not make you immune

The ORMs Python developers use are safe by construction for values. When you write User.objects.filter(email=email) in Django or select(User).where(User.email == email) in SQLAlchemy, the value travels as a bound parameter, separate from the SQL text. Django's security guide says it directly: "A query's SQL code is defined separately from the query's parameters" (Django security).

Injection survives for two reasons. Every ORM offers a way to write SQL text directly, and real applications use it for reporting queries, performance work and features the ORM cannot express. And some parts of a query are not values: table and column names, sort direction, aliases and SQL keywords are part of the statement's structure and must be written into the text. The OWASP Top 10 for 2025 keeps Injection at A05, notes that 100% of tested applications were tested for some form of it, and counts more than 14,000 CVEs for SQL injection alone (CWE-89).

Pattern 1: cursor.execute() with an f-string

The most common pattern is the oldest. A developer drops to a raw cursor and builds the statement with an f-string, % or .format():

# Vulnerable: the value becomes part of the SQL text
def find_orders(cursor, customer):
    cursor.execute(f"SELECT id, total FROM orders WHERE customer = '{customer}'")
    return cursor.fetchall()

Fix it by passing the value as a parameter. The placeholder depends on the driver: %s for Django's connection.cursor() and psycopg, ? for the standard library's sqlite3.

# Django connection.cursor() or psycopg
def find_orders(cursor, customer):
    cursor.execute("SELECT id, total FROM orders WHERE customer = %s", [customer])
    return cursor.fetchall()

# sqlite3
def find_orders_sqlite(conn, customer):
    return conn.execute(
        "SELECT id, total FROM orders WHERE customer = ?", (customer,)
    ).fetchall()

Two near-misses look like fixes and are not. Quoting the placeholder ('%s') is wrong: Django's docs warn explicitly against it (Performing raw SQL queries). And cursor.execute(sql % params) is string formatting with extra steps: the % operator runs in Python before the driver ever sees the query. Pass the parameters as the second argument, always.

Pattern 2: Manager.raw() with formatting

raw() returns model instances from hand-written SQL, so it is popular for reporting views. It accepts a params argument, and Django's docs state "Do not use string formatting on raw queries or quote placeholders in your SQL strings!"

# Vulnerable
Person.objects.raw(f"SELECT * FROM myapp_person WHERE last_name = '{lname}'")

# Fixed
Person.objects.raw("SELECT * FROM myapp_person WHERE last_name = %s", [lname])

Pattern 3: QuerySet.extra()

extra() injects literal clauses into ORM-generated SQL. Django's reference calls it "an old API that we aim to deprecate at some point in the future" and says "We are no longer improving or fixing bugs for this method" (QuerySet.extra). Code that still uses it is often old code nobody has looked at in years, which is exactly where formatted where= strings hide.

# Vulnerable
Entry.objects.extra(where=[f"headline LIKE '%%{term}%%'"])

# Fixed: express it with the ORM
Entry.objects.filter(headline__contains=term)

If you cannot rewrite it yet, use params= for every value, and treat each remaining extra() call as debt with a ticket.

Pattern 4: RawSQL expressions

RawSQL lets you embed a SQL fragment in an annotation or filter. Its params argument is required precisely so values stay out of the text, and the expressions reference warns that "you must escape any parameters that the user can control by using params." The mistake is to satisfy the signature with an empty tuple and format the value in anyway:

from django.db.models.expressions import RawSQL

# Vulnerable: params is present but empty
qs.annotate(score=RawSQL(f"SELECT score FROM ranking WHERE tag = '{tag}'", ()))

# Fixed
qs.annotate(score=RawSQL("SELECT score FROM ranking WHERE tag = %s", (tag,)))

Pattern 5: SQLAlchemy text() in Flask and FastAPI

Flask and FastAPI applications usually reach the database through SQLAlchemy, and text() is the escape hatch. The SQLAlchemy reference lists "backend-neutral support for bind parameters" as the first advantage of text() over a plain string, with named parameters written as :name. Wrapping an f-string in text() throws that advantage away:

from sqlalchemy import text

# Vulnerable: text() around an f-string is still string building
def get_user(session, username):
    return session.execute(
        text(f"SELECT id, email FROM users WHERE username = '{username}'")
    ).first()

# Fixed: a named bind parameter
def get_user_fixed(session, username):
    return session.execute(
        text("SELECT id, email FROM users WHERE username = :username"),
        {"username": username},
    ).first()

The same applies to text() passed into filter(), where() or order_by() of an ORM query. In a FastAPI handler, a typed path or query parameter such as user_id: int is validated before your code runs, which helps for that parameter; a str parameter is still arbitrary text.

Pattern 6: IN lists built by joining strings

Variable-length IN (...) lists push people back to string building:

# Vulnerable
ids_sql = ",".join(ids)
cursor.execute(f"SELECT * FROM items WHERE id IN ({ids_sql})")

Generate one placeholder per value instead, or let the library expand the list:

# DB-API: one placeholder per value
placeholders = ", ".join(["%s"] * len(ids))
cursor.execute(f"SELECT * FROM items WHERE id IN ({placeholders})", ids)

# SQLAlchemy Core: an expanding bind parameter
from sqlalchemy import bindparam, text
stmt = text("SELECT * FROM items WHERE id IN :ids").bindparams(
    bindparam("ids", expanding=True)
)
rows = session.execute(stmt, {"ids": ids}).all()

The f-string in the first fix is safe because it only interpolates placeholders the code generated itself. Guard the empty-list case in the DB-API version: PostgreSQL and MySQL reject IN () as a syntax error, even though SQLite accepts it, so a test suite on SQLite will not catch it.

Pattern 7: ORDER BY and other identifiers

Sort columns, sort direction, table names and column lists are where "always use parameters" stops working, because a database cannot bind an identifier. ORDER BY %s with a parameter sorts by a constant string, which is a no-op, so developers format the column in:

# Vulnerable: an identifier taken from the request
cursor.execute(f"SELECT id, name FROM products ORDER BY {sort} {direction}")

The fix is an allowlist that maps request values to identifiers your code chose:

SORTS = {"name": "name", "price": "price", "newest": "created_at"}
DIRECTIONS = {"asc": "ASC", "desc": "DESC"}

def list_products(cursor, sort, direction):
    column = SORTS.get(sort, "name")
    order = DIRECTIONS.get(direction, "ASC")
    cursor.execute(f"SELECT id, name FROM products ORDER BY {column} {order}")
    return cursor.fetchall()

In SQLAlchemy, map to column objects ({"price": Product.price}) and call .asc() or .desc() on them. If you truly need a dynamic identifier with psycopg, compose it with psycopg.sql.Identifier, which quotes it correctly, but an allowlist is still better: a correctly quoted column the user should not sort by can still leak information through ordering.

Django's order_by() resolves names against model fields and rejects unknown ones, but it has not always been airtight: CVE-2021-35042 was SQL injection in QuerySet.order_by() "if order_by is untrusted input from a client". Allowlist there too.

Pattern 8: names passed into the ORM as keyword arguments

The newest pattern is the subtlest, because there is no SQL in the application at all. When a client controls the names an application passes to the ORM, the framework writes those names into the query. The column-alias CVEs in Django, CVE-2024-42005 and CVE-2025-57833, came from client-chosen JSON keys passed to values() and client-built dictionaries expanded into annotate(**kwargs). CVE-2025-64459 is the same family through a different door: filter(), exclude(), get() and Q() were injectable "when using a suitably crafted dictionary, with dictionary expansion, as the _connector argument".

The antipattern all three reward is expanding a request into the ORM:

# Vulnerable shape: the client chooses keyword names
Product.objects.filter(**request.GET.dict())

# Fixed: the code chooses the names, the client supplies values
ALLOWED = {"category", "brand", "in_stock"}
filters = {k: v for k, v in request.GET.items() if k in ALLOWED}
Product.objects.filter(**filters)

Keep Django current for the framework-side fixes, and let only your code choose keyword names.

How to find these in an existing codebase

Start with a search for the escape hatches: .raw(, .extra(, RawSQL(, cursor.execute(, text(, executescript(, and **request. or **data near an ORM call. Bandit's B608 check flags SQL built by string operations. Review every hit for formatted values and request-controlled identifiers.

Static search has a ceiling. It finds the call, not the data: it cannot tell whether the string reaching execute() holds request data, a constant, or something a worker read back from the database days later, nor see the call reached through an alias or built inside a dependency. That is where runtime evidence helps. es-python, the EyalSec Python runtime, follows untrusted data through the program as it runs and checks it at the operation itself, so it reports a query only when untrusted data actually reached the SQL text, with the path from where it entered. A correctly parameterized query never produces an event, because the value never enters the SQL string. See How EyalSec works and the SQL injection overview.

A short checklist

Pattern Fix
cursor.execute() with f-string or % pass values as the second argument
raw() with formatting params=
extra() rewrite with ORM expressions; params= meanwhile
RawSQL with formatting params tuple with every value
SQLAlchemy text(f"...") :name bind parameters
IN list by join generated placeholders or expanding=True
ORDER BY / column / table from input allowlist to known identifiers
**request into the ORM allowlist keyword names; keep Django current

If you want to see which of these patterns actually receive untrusted data on your own services, you can book a live demo.

EyalSec Pricing Docs Security Research Vulnerabilities Security glossary About Contact עברית Login Book a live demo