SQL injection has been at or near the top of every application security list for more than two decades, and it keeps showing up in Python code that should know better. The reason is almost never exotic. It is a developer who reached for an f-string because it read more cleanly than a parameter list, and in doing so handed the database a query an attacker gets to finish writing.
The one rule
Never build a SQL statement by concatenating or formatting user input into the query string. Pass user input as parameters and let the database driver handle escaping. If you internalize nothing else, internalize that parameters and query text travel through separate channels, and the data channel can never change the meaning of the command.
Here is the difference that decides whether you have a vulnerability:
# VULNERABLE - the input becomes part of the SQL command
name = request.args["name"]
cur.execute(f"SELECT * FROM users WHERE name = '{name}'")
# SAFE - the input is data, and can never alter the query structure
cur.execute("SELECT * FROM users WHERE name = %s", (name,))
With the vulnerable version, a name of ' OR '1'='1 returns every row, and '; DROP TABLE users; -- does what it says. With the parameterized version, the driver sends the value separately and the database treats it as a literal string, full stop.
Placeholders differ by driver
A frequent source of confusion is that the placeholder syntax is not standardized. Using the wrong one silently falls back to string formatting in some codebases, so know your driver:
# psycopg (PostgreSQL) - %s, always a tuple/list of params
cur.execute("SELECT * FROM t WHERE id = %s", (id,))
# sqlite3 (stdlib) - ? placeholders
cur.execute("SELECT * FROM t WHERE id = ?", (id,))
# named parameters (both support a dict form)
cur.execute("SELECT * FROM t WHERE id = :id", {"id": id})
Note that %s here is a driver placeholder, not Python string formatting. You never put the value into the string yourself.
ORMs help, until you leave them
An ORM like SQLAlchemy or the Django ORM parameterizes automatically, which is why ORM-heavy code is largely injection-free. The danger is the escape hatch. The moment you drop to raw SQL, the responsibility comes back to you:
from sqlalchemy import text
# VULNERABLE - f-string into text()
db.execute(text(f"SELECT * FROM orders WHERE user_id = {uid}"))
# SAFE - bound parameter
db.execute(text("SELECT * FROM orders WHERE user_id = :uid"), {"uid": uid})
The same applies to Django's .raw() and .extra(), and to any RawSQL expression. Treat every raw-SQL entry point as a place to double-check that user data is bound, not interpolated.
The case parameters cannot cover: identifiers
Placeholders bind values, not identifiers. You cannot parameterize a table name, a column name, or a sort direction. So dynamic ORDER BY and dynamic column selection are where injection sneaks back in even in disciplined codebases. The fix is an allowlist, never the raw string:
SORT_COLUMNS = {"created", "name", "price"}
SORT_DIRECTIONS = {"asc", "desc"}
def build_order_by(column: str, direction: str) -> str:
if column not in SORT_COLUMNS or direction not in SORT_DIRECTIONS:
raise ValueError("Invalid sort")
return f"ORDER BY {column} {direction}" # values are now known-safe
Because the only values that reach the f-string are ones you explicitly approved, the interpolation is safe. This is the one legitimate exception, and it works precisely because the input has stopped being arbitrary.
Second-order injection
The trickiest variant is second-order injection, where malicious input is safely stored and then unsafely used later. A username saved through a parameterized insert is harmless at write time, but if a later batch job builds a query by interpolating that stored value, the payload fires then. The takeaway: parameterization is required at every point a value reaches SQL, not just at the boundary where it entered. Data does not become trustworthy because it made a round trip through your database.
Defense in depth
Parameterization is the fix. These reduce blast radius if something slips through:
- Grant the application database account least privilege. A read-only report path should connect as a read-only user.
- Do not surface raw database errors to clients; they leak schema and confirm injection points.
- Log and alert on database errors so a probing attacker is visible.
- Add automated testing that feeds injection payloads at your endpoints.
That last point is where a dynamic application security scan earns its place: it fires known injection strings at your running endpoints and reports which ones change the response, catching the raw query someone added last sprint.
Checklist
- No f-strings,
%,.format(), or+building SQL from user input - Correct placeholder syntax for your driver, values passed separately
- Raw-SQL escape hatches (
text(),.raw(),.extra()) use bound params - Dynamic identifiers validated against an allowlist
- Database account runs with least privilege
- Injection payloads covered in automated tests
How Safeguard helps
Injection lives in your own code, so the primary defense is the discipline above. Safeguard complements it from two directions. Our software composition analysis catches the other side of the SQL risk: known injection CVEs in the database drivers and ORM libraries you depend on, which no amount of careful query writing in your code can fix. And because a scanner is only useful if it leads to a fix, automated remediation turns a vulnerable-driver finding into a tested upgrade PR. Teams evaluating dedicated SAST engines for the in-code side often weigh us against incumbents on our comparison against Checkmarx.
Get started
Fix the query patterns in your code first; they are the root cause. Then close the dependency side with Safeguard. Sign up free at app.safeguard.sh/register and see the setup guide at docs.safeguard.sh.