# SQLAlchemy ORM Security: The Raw Query Escape Hatch

> Source: <https://dev.to/coppersundev/sqlalchemy-orm-security-the-raw-query-escape-hatch-2ni5>
> Published: 2026-07-22 17:17:50+00:00

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()`

.

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](https://docs.sqlalchemy.org/en/20/orm/queryguide/query.html) 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.

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:

``` python
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()](https://docs.sqlalchemy.org/en/20/core/sqlelement.html#sqlalchemy.sql.expression.text) explicitly warns against string formatting with caller-supplied values and documents the

`bindparams()`

approach as the correct alternative.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](https://coppersun.dev/blog/sql-injection-ai-generated-python/) 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.

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](https://bandit.readthedocs.io/en/latest/plugins/b608_hardcoded_sql_expressions.html) 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.

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.

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:

``` python
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.
