SQLAlchemy ORM Security: The Raw Query Escape Hatch BrassCoders finds that AI-generated Python code using SQLAlchemy's text() function with f-strings or % formatting introduces SQL injection vulnerabilities, as the ORM's automatic parameterization is bypassed. The company's benchmark data shows that AI assistants favor the syntactically simpler raw SQL path over safe ORM calls, producing correct results but unsafe code. 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.