How do I fix SQL injection in Python?

Direct answer

Pass parameters to `execute()` as a second argument rather than formatting them into the query string. In psycopg2 and most DB-API drivers that means `%s` placeholders with a tuple. The mistake is using f-strings or `.format()` to build SQL, that's the vulnerability, and it looks identical to safe code at a glance.

Muhammad HasanUpdated
# Vulnerable
cursor.execute(f"SELECT * FROM users WHERE email = '{email}'")

The f-string builds the query before the driver sees it, so input becomes SQL.

The fix

# psycopg2 / most DB-API drivers
cursor.execute(
    "SELECT * FROM users WHERE email = %s",
    (email,)
)

The %s here is a placeholder for the driver, not Python string formatting. The driver sends query and values separately and the database never parses input as SQL.

Two things that trip people up. The parameters must be a tuple or list, so a single value needs the trailing comma. And you never quote the placeholder. '%s' is wrong and breaks the mechanism.

Placeholder style varies by driver: %s for psycopg2 and mysqlclient, ? for sqlite3, :name for named parameters in several.

SQLAlchemy

The ORM layer parameterises by default. Raw SQL doesn't.

# Vulnerable
session.execute(text(f"SELECT * FROM users WHERE id = {user_id}"))

# Safe
session.execute(
    text("SELECT * FROM users WHERE id = :id"),
    {"id": user_id}
)

Django

The ORM is safe. The escape hatches are raw(), extra() and connection.cursor().

# Vulnerable
User.objects.raw(f"SELECT * FROM users WHERE email = '{email}'")

# Safe
User.objects.raw("SELECT * FROM users WHERE email = %s", [email])

extra() is worth removing on sight, it's deprecated, hard to use safely, and usually replaceable.

What you can't parameterise

Table and column names.

# Still vulnerable
cursor.execute(f"SELECT * FROM users ORDER BY {sort_column}")

Use an allowlist:

ALLOWED = {"name": "name", "created": "created_at"}
column = ALLOWED.get(sort_column, "created_at")
cursor.execute(f"SELECT * FROM users ORDER BY {column}")

For dynamic identifiers in psycopg2, psycopg2.sql.Identifier handles quoting properly.

How to check what you've got

Search for f-strings, .format() and % formatting near execute, and for raw(, extra( and text(. That covers nearly all of it.

Automated scanning does better here than on most classes, though the numbers are humbler than the reputation. Our RealVuln benchmark scores 26 scanners against 66 Python repositories with hand-labelled ground truth, and on SQL injection the rule-based tools recalled 37% against 96% for LLM-based ones.

A short dataflow and a distinctive pattern are what make it tractable at all. Compare authorisation bugs, where correct and vulnerable code are indistinguishable and rule-based recall collapses. Still, treat a clean SQL injection report from a rule-based scanner as one signal rather than an answer.

Go deeper

Fast remediation with Kolega

Related answers

See what your own repository returns

Connect a repo and run a scan. No credit card, no pipeline changes.

Get started for free