SQL Injection in Python: Example, Exploitation, Detection, and Prevention
Code Security
TL;DR — SQL Injection in Python
- Root cause: f-strings,
.format(),%-formatting, and+concatenation embed user input directly into SQL — the database can't tell the difference between your query and an attacker's payload. - The fix: parameterized queries (
?for sqlite3,%sfor psycopg2) or an ORM. Values are bound after SQL is parsed — injection is structurally impossible, not just filtered. - Easy to miss:
ORDER BYand column names can't be parameterized — use an explicit allowlist instead. - Detection:
grepsurfaces candidates fast, but SAST tools with taint tracking catch what grep misses — especially queries assembled across multiple lines or helper functions. - In production: least-privilege DB accounts limit blast radius; suppressed error messages deny attackers schema information.
Introduction
SQL injection (SQLi) has topped the OWASP Top 10 for over a decade and continues to be the root cause of high-profile data breaches. The attack is simple in concept: user-controlled input is concatenated directly into a SQL query, allowing an attacker to modify the query's logic rather than just supply data values.
SQL injection in Python is one of the most common and consequential vulnerabilities in backend applications today. Python's ecosystem flexibility — quick prototyping with raw database drivers like sqlite3 and psycopg2, multiple web frameworks, and a culture of readable, concise code — creates real opportunities for this bug to slip through code review. A developer writes a login endpoint in Flask using an f-string query. It works. Tests pass. The vulnerability ships to production.
This article covers the full lifecycle of SQL injection in Python: what it is, how attackers exploit it, how to detect it through code review and automated tooling, and — most importantly — how to fix it with concrete, copy-paste-ready examples.
What Is SQL Injection in Python?
SQL injection is a code injection technique where an attacker inserts or "injects" malicious SQL statements into an input field that gets incorporated into a database query. The root cause is treating user input as part of the query's syntax rather than as a literal data value to be passed to the database.
When a database receives a query, it parses the SQL text to build an abstract syntax tree before execution. If attacker-controlled input has already been concatenated into that text, the database cannot distinguish between the developer's intended query structure and the injected commands. The attacker becomes the query author.
🔴 CWE-89 — Improper Neutralization of Special Elements used in an SQL Command. SQL injection is classified under CWE-89 and is consistently ranked in the OWASP Top 10 under A03:2021 Injection. The CVSS base score for exploitable SQL injection in a web application typically ranges from 7.5 (High) to 9.8 (Critical), depending on the database's privilege level and the application's data sensitivity.
Types of SQL Injection
- In-band SQLi — Results are returned directly in the HTTP response. The most common and easiest to detect. Includes error-based and UNION-based variants.
- Blind SQLi (Boolean-based) — No data is returned directly, but the application behaves differently based on whether a condition is true or false. Slower to exploit but equally dangerous.
- Blind SQLi (Time-based) — The attacker infers data by measuring response latency using functions like
SLEEP()orpg_sleep(). - Out-of-band SQLi — Data is exfiltrated via DNS or HTTP requests triggered by the database itself. Less common but used to bypass WAFs.
Python SQL Injection Example (Vulnerable Code)
The following is a realistic login endpoint written with Flask and Python's sqlite3 driver. It looks functional, handles both success and failure cases, and uses standard library components — but it contains a critical SQL injection vulnerability.
# app.py — Vulnerable login endpoint import sqlite3 from flask import Flask, request, jsonify app = Flask(__name__) def get_db(): return sqlite3.connect("users.db") @app.route("/login", methods=["POST"]) def login(): username = request.form["username"] password = request.form["password"] db = get_db() cursor = db.cursor() # ❌ VULNERABLE: user input concatenated directly into the query string query = f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'" cursor.execute(query) user = cursor.fetchone() if user: return jsonify({"status": "success", "user_id": user[0]}) else: return jsonify({"status": "error", "message": "Invalid credentials"}), 401
The problem is on the highlighted line. The f-string embeds username and password directly into the SQL string. The database receives a fully-formed SQL statement where both values are part of the query text itself — not bound as parameters. Any SQL metacharacters the attacker includes (', --, ;, OR) are interpreted as SQL syntax.
The same vulnerability appears in codebases using %-formatting or +-concatenation:
# Other vulnerable patterns in Python # Pattern 1: % string formatting query = "SELECT * FROM users WHERE username = '%s'" % username # Pattern 2: string concatenation query = "SELECT * FROM orders WHERE user_id = " + user_id # Pattern 3: .format() — equally vulnerable query = "SELECT * FROM products WHERE id = {}".format(product_id) # Pattern 4: dynamic ORDER BY — commonly missed query = f"SELECT * FROM logs ORDER BY {sort_column} DESC"
⚠️ The ORDER BY pattern is frequently missed by code review. You cannot parameterize column names or SQL keywords — only values. Dynamic column names require explicit allowlisting, not parameterization.
This allows payloads like ' OR '1'='1 to turn the WHERE clause into a tautology — returning every row in the table. A payload like admin'-- eliminates the password check entirely by commenting out the rest of the query.
How Attackers Exploit SQL Injection
Given the vulnerable login endpoint above, here is what an attacker actually sends and what happens at the database level.
Authentication bypass — entering admin' -- as the username causes the database to ignore the password check entirely:
WHERE username = 'admin' -- (password check removed by comment operator)
Data exfiltration via UNION — if query results are reflected in the response, an attacker appends a UNION SELECT to read arbitrary tables:
' UNION SELECT username, password, NULL FROM users --
Schema discovery works the same way: ' UNION SELECT name, sql, NULL FROM sqlite_master -- returns table and column names, giving the attacker a full map of the database.
Common SQL injection payloads
| Payload | Technique | Impact | Risk |
|---|---|---|---|
| ' OR '1'='1 | Boolean tautology | Returns all rows, bypasses WHERE filter | Critical |
| admin'-- | Comment truncation | Authentication bypass for known username | Critical |
| ' UNION SELECT null,null-- | UNION-based | Column count detection, precursor to exfiltration | High |
| 1; DROP TABLE users-- | Stacked queries | Data destruction (driver-dependent) | Critical |
| 1 AND SLEEP(5)-- | Time-based blind | Confirms injection point, enables data extraction | High |
How to Detect SQL Injection in Python
Manual code review
The first line of defense is knowing what patterns to look for during review. In Python, the signal is clear: a string passed to .execute() or similar methods that was assembled using f-strings, %-formatting, .format(), or +-concatenation.
A targeted grep can surface candidates quickly across a codebase:
# Find .execute() calls that use f-strings or % formatting grep -rn "\.execute(f['\"]" ./app/ grep -rn "\.execute(.*%.*)" ./app/ grep -rn "\.execute(.*\.format(" ./app/ # Find raw query construction with concatenation grep -rn "SELECT.*+\s*" ./app/ --include="*.py" grep -rn "WHERE.*+\s*" ./app/ --include="*.py" # Broader: any execute() call — review manually grep -rn "cursor\.execute\|db\.execute\|conn\.execute" ./app/ --include="*.py"
Grep is useful for a quick scan, but it produces false positives for parameterized queries and misses cases where the query is assembled across multiple lines or helper functions. It is a starting point, not a definitive audit.
Static analysis tools (SAST)
For automated detection at scale, SAST tools analyze data flow: they trace where user-controlled values originate (HTTP request parameters, environment variables, file reads) and whether they reach a sink — a function that executes SQL — without passing through a sanitizer or parameterization step.
Common options include pattern-based scanners like Bandit and Semgrep, and semantic analyzers like CodeQL. Each varies in precision, false positive rate, and setup overhead — for a detailed comparison, see Best SAST Tools in 2026.
Flask SQL Injection Vulnerability
Unlike login examples, SQL injection frequently appears in search endpoints using LIKE clauses — which developers often assume are safe because they don't handle authentication. The pattern below is common in Flask filter and search endpoints.
Vulnerable Flask endpoint using request.args
# Flask search endpoint — vulnerable from flask import Flask, request, jsonify import sqlite3 app = Flask(__name__) @app.route("/search") def search(): # request.args pulls directly from the URL query string term = request.args.get("q", "") conn = sqlite3.connect("products.db") cursor = conn.cursor() # ❌ term is user-controlled — never embed in SQL string query = f"SELECT * FROM products WHERE name LIKE '%{term}%'" cursor.execute(query) results = cursor.fetchall() return jsonify(results)
Attack request targeting this endpoint
GET /search?q=%' UNION SELECT username,password,NULL FROM users-- -- Results in: WHERE name LIKE '%' UNION SELECT username,password,NULL FROM users--%' -- The entire users table is returned in place of product results.
Fixed Flask endpoint — parameterized query
# Flask search endpoint — secure @app.route("/search") def search(): term = request.args.get("q", "") conn = sqlite3.connect("products.db") cursor = conn.cursor() # ✅ Wildcard characters added in Python, value passed as parameter cursor.execute( "SELECT * FROM products WHERE name LIKE ?", (f"%{term}%",) ) results = cursor.fetchall() return jsonify(results)
💡 Note on LIKE patterns: The % wildcard characters must be added in Python before the value is passed as a parameter — not inside the SQL string itself. The database driver handles escaping the contents of term, including any % or _ characters the user might include.
How to Fix SQL Injection in Python
The correct fix for SQL injection is parameterized queries — also called prepared statements. Instead of embedding values into the query string, you pass them as a separate argument to the driver. The database engine then handles escaping internally, ensuring that values are always treated as data, never as SQL syntax.
Fix 1: Parameterized queries with sqlite3 / psycopg2
# sqlite3 — use ? placeholders query = "SELECT * FROM users WHERE username = ? AND password = ?" cursor.execute(query, (username, password)) # psycopg2 / MySQL — use %s placeholders query = "SELECT * FROM users WHERE username = %s AND password = %s" cursor.execute(query, (username, password))
Values are passed as a separate tuple — never embedded in the SQL string. The database parses the SQL syntax tree first, then binds the values afterward, meaning user input never becomes part of the SQL structure. Injection is structurally impossible, not just filtered.
Fix 2: SQLAlchemy ORM (recommended for application code)
# Secure query with SQLAlchemy ORM from sqlalchemy.orm import Session from models import User def get_user(db: Session, username: str, password: str): # ORM methods always parameterize internally — no raw strings return db.query(User).filter_by(username=username, password=password).first() # If you need raw SQL with SQLAlchemy, use text() with bound params: from sqlalchemy import text def get_user_raw(db: Session, username: str): stmt = text("SELECT * FROM users WHERE username = :username") return db.execute(stmt, {"username": username}).fetchone()
Fix 3: Dynamic ORDER BY — allowlisting
Column names and SQL keywords cannot be parameterized. For dynamic ORDER BY or column selection, use an explicit allowlist:
# ❌ VULNERABLE — never do this query = f"SELECT * FROM logs ORDER BY {request.args.get('sort')} DESC" # ✅ SECURE — explicit allowlist of valid column names ALLOWED_SORT_COLUMNS = {"created_at", "severity", "user_id"} sort_param = request.args.get("sort", "created_at") sort_col = sort_param if sort_param in ALLOWED_SORT_COLUMNS else "created_at" query = text(f"SELECT * FROM logs ORDER BY {sort_col} DESC") cursor.execute(query)
Quick reference — safe vs unsafe patterns
| Pattern | Safe? | Reason |
|---|---|---|
f"SELECT ... {user_input}" | ❌ | Input becomes part of SQL syntax |
"SELECT ..." % user_input | ❌ | No separation between query and data |
"SELECT ...".format(user_input) | ❌ | No separation between query and data |
cursor.execute(query, (value,)) | ✅ | Value bound after SQL is parsed |
db.query(Model).filter_by(...) | ✅ | ORM parameterizes internally |
text("... :param") + bound params | ✅ | Explicit binding in raw SQLAlchemy |
Why Traditional SAST Tools Produce False Positives
Pattern-based SAST tools work by matching source code against a library of known-bad patterns — essentially grep with data flow awareness. This approach has a fundamental limitation: the tool flags any code that looks like a vulnerability, regardless of whether it is actually exploitable. For a broader breakdown of how SAST, DAST, and SCA differ in practice, see SAST vs DAST vs SCA: What's the Difference.
A SAST tool flags a cursor.execute() call that constructs a query with an f-string — but the only variable interpolated is an integer user_id that has already been validated and cast via int(). The query is not injectable, but the tool reports a critical finding regardless.
An AI-native analyzer traces the complete data flow: it determines that user_id originates from a JWT-validated session, passes through an int() cast, and reaches the query with no user-controlled string interpolation. No finding is raised.
False positives have a real cost. When a tool reports 200 findings per week and 60–80% are false positives, engineers start triaging mechanically rather than carefully. Real vulnerabilities get dismissed in the noise. The tool loses credibility, gets disabled in CI, and the security program breaks down.
The three most common sources of false positives for SQL injection specifically are: values that look like user input but originate from internal configuration; string formatting used in query construction where the interpolated value is a validated constant; and ORMs that generate parameterized queries internally but whose intermediate string representations trigger pattern matches.
Detecting Real SQL Injection Vulnerabilities with Precogs AI
Most SAST tools detect SQL injection by pattern-matching: they flag any .execute() call assembled with an f-string or + operator, regardless of whether the value is actually user-controlled. Precogs AI uses taint analysis instead — tracing data from HTTP sources through your call graph to SQL sinks, and raising a finding only when exploitability is confirmed. Findings surface directly inside PRs and CI pipelines, mapped to OWASP Top 10 and CWE Top 25, so security context arrives where developers are already working.
Figures based on the CASTLE benchmark evaluation.
Precogs AI surfaces the exact taint path — from HTTP input source through to SQL execution sink — alongside a code diff showing the vulnerable line and the parameterized fix.
not injectable
shown to developer
| Capability | Pattern-based SAST | Precogs AI |
|---|---|---|
| Detection method | Regex / AST pattern matching | Full taint-path analysis |
| Tracks data through function calls | ✗ No | ✓ Yes |
| Understands type casts (int()) | ✗ No | ✓ Yes |
| ORM-aware (SQLAlchemy, Django) | ✗ Limited | ✓ Yes |
| False positive rate | High (60–80% noise typical) | 63× lower in benchmarks |
| Finding includes taint path | ✗ No | ✓ Yes — line-by-line |
| CI/CD integration | Varies | GitHub & GitLab via REST API |
Best Practices for Preventing SQL Injection in Python
Parameterized queries fix the vulnerability at the code level. These practices build defense in depth at the architecture and process level.
SELECT, INSERT, UPDATE, DELETE on the tables it needs. Never DROP, CREATE, or access to system tables.In practice, teams rarely apply all of these consistently across a growing codebase. This is where tools like Precogs AI become critical — automatically enforcing these practices by tracing user input to SQL execution paths and identifying only confirmed, exploitable vulnerabilities.
Frequently Asked Questions
.execute() rather than embedding them in the SQL string. For application code, use an ORM like SQLAlchemy or Django ORM as the default data access layer, since both parameterize internally. For dynamic identifiers such as column names or sort directions — which cannot be parameterized — validate against an explicit allowlist before interpolation. Layering in least-privilege database accounts and SAST in CI provides additional defence-in-depth.filter(), filter_by(), get() — yes, parameters are always bound internally and injection is not possible through those APIs. However, ORMs provide no protection when you bypass them. SQLAlchemy's .execute() with a plain string, Django's .raw(), and extra(where=...) all carry the same risk as raw sqlite3 or psycopg2 calls. If you use these escape hatches, always pair them with text() and bound parameters.f"... {user_input}"), %-formatting, .format(), and string concatenation with +. In all these cases, the database receives a fully-formed SQL string it cannot distinguish from a developer-authored query — any SQL metacharacters the user includes are interpreted as syntax rather than data..execute() call where the query string is assembled with f-strings, .format(), %-formatting, or + concatenation. For automated detection at scale, static analysis tools trace whether user-controlled values reach SQL execution points without parameterization. AI-native platforms like Precogs AI take this further by performing full taint-path analysis — only surfacing confirmed, exploitable findings rather than pattern matches, which significantly reduces false positive noise.Conclusion
SQL injection in Python is not a subtle or complex vulnerability — it has a clear cause and a definitive fix. Every instance traces back to the same root: user-controlled input treated as SQL syntax rather than bound as a parameter. Switching to parameterized queries with sqlite3, psycopg2, or SQLAlchemy's text() eliminates the vulnerability structurally, not through filtering or escaping.
Defense in depth — least-privilege database accounts, suppressed error messages in production, SAST in CI, and security unit tests — reduces the blast radius if something slips through. But none of those layers substitute for parameterized queries at every database call.
If you want to verify your Python codebase is clean, Precogs AI traces taint paths from HTTP inputs to SQL sinks and surfaces only confirmed, exploitable findings — so your team spends time fixing real vulnerabilities, not triaging noise.
