SQLAlchemy ORM Security: The Raw Query Escape Hatch
SQLAlchemy's ORM parameterizes by default. But when AI assistants hit complex queries, they reach for text() with f-strings — and Bandit B608 fires on that pattern every time.
SQLAlchemy’s ORM is one of the better defenses against SQL injection available to Python developers. Normal ORM calls — filter(), filter_by(), query() — compile to parameterized queries. The database receives the value separately from the SQL string. String-formatted SQL injection is structurally impossible through that path.
The problem is text().
Why the ORM Usually Protects You
BrassCoders finds very few SQL injection findings in codebases that stay within SQLAlchemy’s ORM layer because the ORM compiles to parameterized queries by design — values travel through bind parameters, not string concatenation.
When an AI assistant writes session.query(User).filter(User.id == user_id).first(), SQLAlchemy compiles this to SELECT * FROM users WHERE id = ? and passes user_id as a separate value. The database driver never sees the value merged into the SQL string. No amount of SQL syntax in user_id can modify the query structure.
The SQLAlchemy documentation calls this parameterization automatic and default. You don’t opt into it. You’d have to go out of your way to break it. And that’s exactly what text() lets you do.
The text() Escape Hatch
BrassCoders flags text() calls with f-strings or % formatting via Bandit B608 — the same rule that fires on raw cursor.execute() calls — because the query string reaching the database is already interpolated before parameterization can happen.
Here’s the pattern AI assistants produce:
from sqlalchemy import text
def get_orders_by_status(session, status, min_amount):
stmt = text(
f"SELECT * FROM orders "
f"WHERE status = '{status}' AND amount >= {min_amount} "
f"ORDER BY created_at DESC"
)
return session.execute(stmt).fetchall()
The f-string builds a complete SQL string before text() receives it. SQLAlchemy wraps it in a TextClause object, but the values are already fused into the string. Passing status = "shipped' OR '1'='1" turns the WHERE clause into WHERE status = 'shipped' OR '1'='1' — every order in the database, regardless of amount.
The database sees a string. There’s nothing to parameterize. The ORM wrapper changes nothing about the injection surface.
SQLAlchemy’s own documentation on text() explicitly warns against string formatting with caller-supplied values and documents the bindparams() approach as the correct alternative.
Why AI Assistants Take It
BrassCoders’ benchmark data shows the same generation-mode pattern across SQL injection findings: the model reaches for the syntactically simpler path, which for complex queries means text() and a formatted string rather than 10-15 lines of chained SQLAlchemy ORM calls.
The ORM path for a complex multi-condition query with dynamic ORDER BY, LIMIT, or subquery logic can span 10-15 lines of chained SQLAlchemy calls. The raw SQL equivalent is 3 lines and immediately readable. An AI assistant satisfying a prompt like “write a function that returns orders filtered by status, amount, and date range, sorted by creation date” produces correct results faster with text() and a formatted string.
The model doesn’t evaluate parameterization. It evaluates whether the output satisfies the prompt. “Returns the right rows” is a prompt-level success. “Safe against injection” is a separate evaluation the model skips unless you ask.
This is the same generation-mode finding from BrassCoders’ benchmark on raw connections (see the sql-injection-ai-generated-python.md post) — the model introduced the vulnerability without warning, and would catch it if asked to review. A scan that runs automatically on every commit is a different tool from a model you have to remember to invoke.
What Bandit B608 Finds
BrassCoders includes Bandit, the de facto Python security linter, as one of its 12 static-analysis scanners. Bandit’s B608 rule — “Possible SQL injection via string-based query construction” — matches any string-formatting operation in a context that looks like a SQL query, regardless of which function or method wraps the result.
The B608 rule documentation describes the detection pattern in detail. B608 doesn’t inspect what you pass the string to. It fires on the formatting pattern itself. A text(f"SELECT ... {value}") call and a cursor.execute("SELECT ... %s" % value) call trigger the same rule because the dangerous operation — merging caller-supplied data into a query string — is identical in both cases.
Severity: medium. Confidence: medium. Those ratings reflect that B608 can’t verify whether the value comes from caller input — but in the escape-hatch pattern, it always does. If you’re calling text() with a formatted string, you’re calling it because the query includes a dynamic value.
How to Reproduce It
If you have BrassCoders installed, create a file with the pattern above and run it offline:
brasscoders --offline scan /path/to/project
B608 fires on the text(f"...") line. The finding in .brass/ai_instructions.yaml names the file, line number, and severity, and includes a remediation note pointing to named bind parameters.
You can also reproduce the raw-connection variant from BrassCoders’ published benchmark corpus:
git clone https://github.com/CopperSunDev/brasscoders
pip install brasscoders
brasscoders --offline scan brasscoders/docs/benchmarks/ai-code-findings-corpus/files
The corpus B608 findings are in user_lookup.py and bulk_insert.py — raw cursor.execute() calls with % formatting. Same rule, same fix. The ORM escape-hatch version produces an identical finding.
The Fix: Parameterization Inside text()
BrassCoders surfaces the fix alongside the B608 finding: use SQLAlchemy’s named bind parameters inside text() rather than string interpolation, and B608 goes silent because the query string contains no formatting operators.
SQLAlchemy’s text() function supports named bind parameters. The colon-prefixed syntax keeps the query string static and the value separate:
from sqlalchemy import text
def get_orders_by_status(session, status, min_amount):
stmt = text(
"SELECT * FROM orders "
"WHERE status = :status AND amount >= :min_amount "
"ORDER BY created_at DESC"
)
return session.execute(stmt, {"status": status, "min_amount": min_amount}).fetchall()
:status and :min_amount are placeholders. SQLAlchemy passes {"status": status, "min_amount": min_amount} as bind parameters — the database driver handles isolation. A value of "shipped' OR '1'='1" goes through as the literal string shipped' OR '1'='1, not as SQL syntax.
B608 does not fire on this pattern. The query string contains no formatting operators. The value never touches the string.
For queries that fit within the ORM’s expression language, the full ORM path avoids text() entirely:
session.query(Order).filter(
Order.status == status,
Order.amount >= min_amount
).order_by(Order.created_at.desc()).all()
This compiles to a parameterized query with no text() involved. B608 has nothing to flag. SQLAlchemy handles parameterization automatically.
The ORM path requires knowing SQLAlchemy’s API. The text() path requires knowing SQL. AI assistants know both — but they reach for SQL when the query is complex, because SQL is more direct. The fix is knowing where the escape hatch leads.
BrassCoders version 2.0.10 catches this pattern in the offline scan, no API call required. Install via pip install brasscoders and run brasscoders --offline scan on any Python project using SQLAlchemy.
Frequently Asked Questions
Does SQLAlchemy prevent SQL injection?
Standard SQLAlchemy ORM calls — filter(), filter_by(), and query() — are parameterized by default. The text() escape hatch bypasses that protection when you interpolate values with an f-string or % formatting instead of using SQLAlchemy's named bindparams. BrassCoders flags the unsafe text() pattern via Bandit B608.
Why does BrassCoders flag text() calls?
BrassCoders runs Bandit B608, which matches string-formatting patterns in SQL query strings regardless of which wrapper the string is passed to. A text() call with an f-string interpolation is structurally identical to a raw cursor.execute() with % formatting — the database receives an already-interpolated string, and parameterization never happens.
Why do AI assistants use text() with f-strings?
AI assistants reach for text() when a query is complex enough that the ORM equivalent requires multiple chained calls. Raw SQL is syntactically simpler to express and satisfies the prompt faster. The model sees text() as the straightforward path to the correct result — it doesn't evaluate whether the caller-supplied values should be isolated from the query string.
Is this different from the SQL injection in the N=15 corpus?
Same Bandit rule — B608 — different context. The sql-injection-ai-generated-python.md post covers raw cursor.execute() calls on a direct database connection. This post covers the ORM escape hatch: text() calls inside a SQLAlchemy session.execute(). The injection mechanism is identical. The difference is that developers using an ORM often assume they're protected, which is why the escape-hatch pattern is worth calling out separately.
What is the correct fix for text() with an f-string?
Use SQLAlchemy's named bind parameters inside text(). Replace text(f'SELECT * FROM users WHERE id = {user_id}') with text('SELECT * FROM users WHERE id = :user_id') and pass session.execute(stmt, {'user_id': user_id}). The value travels separately from the query string and the database driver handles escaping. For queries simple enough to express it, the full ORM query expression — session.query(User).filter(User.id == user_id) — avoids the escape hatch entirely.