The f-string builds the query before the driver sees it, so input becomes SQL.
The fix
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.
Django
The ORM is safe. The escape hatches are raw(), extra() and connection.cursor().
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.
Use an allowlist:
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.