Why this matters
The bug. Python f-strings are *string concatenation*, not parameter binding. The driver receives the attacker's text as part of the SQL grammar, so user_id = '1; DROP TABLE users--' is no longer a value — it's syntax.
The fix. Use a placeholder (%s for psycopg, ? for sqlite3, $1 for asyncpg) and pass the value as a separate argument to execute. The driver escapes it according to the wire protocol, not the SQL grammar.
Code-review heuristic. Any time you see f"..." or + building a SQL string with a name that came from a request, stop and ask: where did that value originate?
Review heuristic
Every SQL string built from concatenation is guilty until proven innocent. If the value originated in a request — directly or indirectly through a stored field a user once supplied — it has to flow through a parameterized binding. ORDER BY and dynamic table names need an allowlist, never a passthrough.
External reference: CWE-89: Improper Neutralization of Special Elements used in an SQL Command.
↳ OWASP Top 10 A03 (Injection); CWE-89.