flawopen.com/SQL Injection/Python
Imagine a form that only ever expects a ticket number, like 482. SQL injection is what happens when someone types something sneaky into that box instead of a number — a trick phrase that makes the system say "show me every ticket" instead of just ticket 482, because the system never checked that what it received was actually just a number.
SQL injection happens when user-controlled input gets inserted directly into a database query's text, instead of being passed as a separate value. If your code builds a query by gluing strings together, an attacker can supply input that changes the query's actual structure — turning a "look up one row" query into one that returns every row, or deletes a table.
In Python, this almost always shows up the same way: a database call built with an f-string, % formatting, or plain + concatenation instead of the database driver's built-in parameter placeholders.
In 2015, UK telecom TalkTalk suffered a breach affecting over 150,000 customers after attackers exploited a SQL injection flaw in a legacy web page inherited through a company acquisition. The UK's data protection regulator fined TalkTalk £400,000, describing the failure as preventable and basic.
Source: UK Information Commissioner's Office enforcement notice, 2016 — see References below.# user_id comes straight from the request
def get_user(cursor, user_id):
query = f"SELECT * FROM users WHERE id = {user_id}"
cursor.execute(query)
return cursor.fetchone()
# value is passed separately, never inlined
def get_user(cursor, user_id):
query = "SELECT * FROM users WHERE id = %s"
cursor.execute(query, (user_id,))
return cursor.fetchone()
The fixed version passes the query text and the value to execute() as two separate arguments. The database driver sends them to the database separately too — the query's structure is fixed before the value is ever attached to it, so the value can never be interpreted as part of the SQL syntax, no matter what characters it contains. An f-string can't do this, because by the time execute() sees the query, the value has already been baked into the text as if it were always part of the command.
cursor.execute("... WHERE id = %s" % user_id) is just as vulnerable as an f-string. The placeholder only becomes safe when the value is passed as execute()'s own second argument — cursor.execute("...WHERE id = %s", (user_id,)) — so the driver, not Python's string formatting, does the substitution.
Django's ORM and SQLAlchemy's query builder both parameterize automatically for normal queries. The risk comes back the moment you drop into Model.objects.raw() or SQLAlchemy's text() and build that raw SQL with an f-string.
psycopg2 (PostgreSQL) uses %s regardless of column type; sqlite3 uses ?. Copying a placeholder style from one driver's docs into another silently breaks — check your specific driver's parameter style rather than assuming.
True for the ORM's normal query API — false the moment you drop into raw() or text() and build that string yourself.
The risk isn't the value's type at runtime — it's that the query is built by string interpolation at all. The moment that assumption breaks anywhere in the code's lifetime, the vulnerability is already there waiting.
Manual escaping is driver-specific and easy to get subtly wrong. Parameterized queries aren't a stricter version of escaping — they avoid the problem entirely, because the value is never part of the query text.
grep -rn "execute(f\"" --include="*.py" .
grep -rn "execute(.*%\s*(" --include="*.py" .
grep -rn "\.raw(\|text(" --include="*.py" .
For its normal query methods, yes. Its raw-query escape hatches don't — those are exactly as safe as hand-written SQL, no safer.
No. Anything effectively controlled by an outside party counts — HTTP headers, uploaded filenames, even a value from a third-party API your app trusts.
You can, but it's fragile and driver-specific. Parameterized queries are the actual fix, not a stricter version of escaping.