Safeguard
Industry Analysis

SQL Injection Prevention in Python with Parameterized Que...

SQL injection remains widespread in Python apps despite decades-old fixes. Here's how parameterized queries actually prevent it, and where ORMs still leave gaps.

Aman Khan
AppSec Engineer
7 min read

In March 2024, a security researcher disclosed a SQL injection vulnerability in a widely used Python-based inventory management tool that allowed unauthenticated attackers to dump entire customer databases with a single crafted request. The root cause was unremarkable: a developer had built a query using an f-string instead of a parameterized statement. This pattern repeats constantly across Python codebases, from Flask APIs to Django admin panels to internal data pipelines built with raw psycopg2 or sqlite3 calls. SQL injection has ranked in the OWASP Top 10 since the list's inception in 2003, and it remains one of the most common findings in application security audits today. The fix is well understood and has existed in Python's database libraries for decades: parameterized queries, also called prepared statements. This post explains why string concatenation fails, how parameterized queries close the gap, and how to verify your codebase is actually using them consistently rather than assuming it is.

What Makes SQL Injection Still Possible in Python in 2026?

SQL injection is still possible because Python's database drivers let developers build queries as plain strings, and string concatenation offers no separation between code and data. When a developer writes cursor.execute(f"SELECT * FROM users WHERE username = '{username}'"), the database receives a single blob of text and has no way to distinguish the intended query structure from user-supplied content. If username arrives as ' OR '1'='1, the resulting query becomes SELECT * FROM users WHERE username = '' OR '1'='1', which returns every row in the table. This isn't a Python-specific flaw — it's how relational databases have always parsed SQL text — but Python's flexibility with string formatting (%, .format(), f-strings, and now the walrus operator inside expressions) gives developers more ways than ever to accidentally build unsafe queries while the code still looks clean and readable.

How Do Parameterized Queries Actually Prevent Injection?

Parameterized queries prevent injection by sending the query structure and the user-supplied values to the database as two separate channels instead of one merged string. In Python's DB-API 2.0 specification (PEP 249), every compliant driver — psycopg2, mysql-connector-python, sqlite3, pyodbc — supports passing parameters as a second argument to execute(). For example: cursor.execute("SELECT * FROM users WHERE username = %s", (username,)). The database receives the literal template SELECT * FROM users WHERE username = ? and compiles it once, then binds username strictly as a data value in the parameter slot. Even if username contains ' OR '1'='1, the database treats it as a single string literal to compare against, not as executable SQL. This is the same mechanism that made prepared statements a security control in the SQL standard itself, not a Python-specific workaround, which is why it applies equally to ORMs like SQLAlchemy and Django's ORM when used correctly.

Which Python Query Patterns Are Actually Safe Versus Dangerous?

The safe patterns all share one trait: user input never touches the query string directly, while the dangerous patterns all build the query text dynamically. Safe: cursor.execute("SELECT * FROM orders WHERE id = %s", (order_id,)) using psycopg2's placeholder syntax, or User.objects.filter(username=username) in Django's ORM, or session.query(User).filter(User.username == username) in SQLAlchemy. Dangerous: f-strings (f"SELECT * FROM t WHERE id={id}"), % formatting applied before execute() instead of passed as the second argument, .format() calls building WHERE clauses, and str.join() on user-controlled lists of column names. A subtler trap is cursor.execute(query % params) — this looks similar to the parameterized form but performs Python-level string substitution before the driver ever sees it, which is functionally identical to concatenation. Django's .raw() and .extra() methods and SQLAlchemy's text() construct also reintroduce risk if developers interpolate variables into the raw SQL string rather than using the bound-parameter syntax those functions still support.

What Do Real-World Python SQL Injection Incidents Look Like?

Real-world Python SQL injection incidents typically trace back to a single unparameterized query buried in an otherwise secure codebase, not a wholesale absence of security practices. CVE-2023-32677, affecting the Ray distributed computing framework, involved improperly sanitized inputs reaching backend queries. In 2019, a widely reported breach of a healthcare scheduling platform built on Django stemmed from a custom raw SQL report-generation endpoint that concatenated a date-range filter supplied via query parameters. In both cases, the surrounding application used an ORM correctly for 95% of its data access, but one custom reporting or admin feature dropped down to raw SQL for performance or flexibility reasons and skipped parameterization. This is the pattern security teams see repeatedly: injection risk concentrates in analytics endpoints, admin tooling, and legacy scripts that predate a team's adoption of ORM conventions, making these areas disproportionately important to review even after "the app" has been broadly secured.

Are ORMs Like Django and SQLAlchemy Automatically Safe from Injection?

ORMs are safe from injection only when developers stick to the ORM's query-building API and avoid its raw-SQL escape hatches without parameters. Django's ORM parameterizes queries built through filter(), exclude(), get(), and similar methods by default, but Model.objects.raw(f"SELECT * FROM app_user WHERE email = '{email}'") bypasses that protection entirely and is exploitable exactly like raw psycopg2 code. SQLAlchemy behaves the same way: select(User).where(User.name == name) is parameterized automatically, while session.execute(text(f"SELECT * FROM users WHERE name = '{name}'")) is not, even though text() is a legitimate, commonly used SQLAlchemy function. Security reviews that stop at "the project uses an ORM" and conclude the injection risk is handled miss this distinction consistently, because the presence of an ORM in a codebase says nothing about whether every query path uses it correctly.

How Should Teams Verify Parameterization Across a Large Python Codebase?

Teams should verify parameterization through automated static analysis rather than manual code review, because manual review does not scale past a few thousand lines and injection risk hides in exactly the endpoints reviewers are least likely to prioritize. Tools like Bandit (B608: hardcoded SQL expressions) and Semgrep's Python SQL injection rulesets can flag f-string and .format() usage adjacent to execute() calls, but they produce false positives on legitimate dynamic table-name construction and false negatives on custom query-builder wrappers that obscure the eventual execute() call. Effective verification combines pattern-based static analysis with dependency and framework awareness — knowing which of your 200 microservices use raw psycopg2 versus Django ORM versus SQLAlchemy Core changes where you should concentrate manual review effort. This is where treating SQL injection prevention as a continuous supply-chain-security question, not a one-time audit, produces materially better outcomes: new services and new contributors reintroduce the same f-string pattern indefinitely unless the check runs on every change.

How Safeguard Helps

Safeguard approaches SQL injection risk as a software supply chain problem: the vulnerable pattern doesn't just live in your own repositories, it can arrive through a dependency, a forked internal library, or a contributor unfamiliar with your team's conventions, and it needs continuous detection rather than a single point-in-time audit. Safeguard's platform scans Python codebases and their dependency graphs for unparameterized query construction — including f-strings, .format(), and %-formatting feeding into execute(), raw(), and text() calls — and flags them with the specific file, line, and data flow path from user input to query execution, so security teams don't have to manually trace request handlers to database calls. Because Safeguard integrates into CI/CD pipelines, these checks run on every pull request across every service, catching the one raw-SQL reporting endpoint or admin script before it merges rather than after an incident. For teams managing dozens or hundreds of Python services with mixed ORM and raw-driver usage, Safeguard also maps which services rely on which database access patterns, giving security leads a prioritized view of where parameterization gaps are most likely to exist and where manual review time is best spent. The goal is straightforward: make the safe pattern the default outcome of your pipeline, not a policy that depends on every engineer remembering PEP 249 on every pull request.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.