{"slug": "sqlalchemy-orm-security-the-raw-query-escape-hatch", "title": "SQLAlchemy ORM Security: The Raw Query Escape Hatch", "summary": "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.", "body_md": "SQLAlchemy's ORM is one of the better defenses against SQL injection available to Python developers. Normal ORM calls — `filter()`\n\n, `filter_by()`\n\n, `query()`\n\n— compile to parameterized queries. The database receives the value separately from the SQL string. String-formatted SQL injection is structurally impossible through that path.\n\nThe problem is `text()`\n\n.\n\nBrassCoders 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.\n\nWhen an AI assistant writes `session.query(User).filter(User.id == user_id).first()`\n\n, SQLAlchemy compiles this to `SELECT * FROM users WHERE id = ?`\n\nand passes `user_id`\n\nas a separate value. The database driver never sees the value merged into the SQL string. No amount of SQL syntax in `user_id`\n\ncan modify the query structure.\n\nThe [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()`\n\nlets you do.\n\nBrassCoders flags `text()`\n\ncalls with f-strings or `%`\n\nformatting via Bandit B608 — the same rule that fires on raw `cursor.execute()`\n\ncalls — because the query string reaching the database is already interpolated before parameterization can happen.\n\nHere's the pattern AI assistants produce:\n\n``` python\nfrom sqlalchemy import text\n\ndef get_orders_by_status(session, status, min_amount):\n    stmt = text(\n        f\"SELECT * FROM orders \"\n        f\"WHERE status = '{status}' AND amount >= {min_amount} \"\n        f\"ORDER BY created_at DESC\"\n    )\n    return session.execute(stmt).fetchall()\n```\n\nThe f-string builds a complete SQL string before `text()`\n\nreceives it. SQLAlchemy wraps it in a `TextClause`\n\nobject, but the values are already fused into the string. Passing `status = \"shipped' OR '1'='1\"`\n\nturns the WHERE clause into `WHERE status = 'shipped' OR '1'='1'`\n\n— every order in the database, regardless of amount.\n\nThe database sees a string. There's nothing to parameterize. The ORM wrapper changes nothing about the injection surface.\n\n[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\n\n`bindparams()`\n\napproach 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()`\n\nand a formatted string rather than 10-15 lines of chained SQLAlchemy ORM calls.\n\nThe 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()`\n\nand a formatted string.\n\nThe 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.\n\nThis 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.\n\nBrassCoders 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.\n\nThe [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}\")`\n\ncall and a `cursor.execute(\"SELECT ... %s\" % value)`\n\ncall trigger the same rule because the dangerous operation — merging caller-supplied data into a query string — is identical in both cases.\n\nSeverity: 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()`\n\nwith a formatted string, you're calling it because the query includes a dynamic value.\n\nIf you have BrassCoders installed, create a file with the pattern above and run it offline:\n\n```\nbrasscoders --offline scan /path/to/project\n```\n\nB608 fires on the `text(f\"...\")`\n\nline. The finding in `.brass/ai_instructions.yaml`\n\nnames the file, line number, and severity, and includes a remediation note pointing to named bind parameters.\n\nYou can also reproduce the raw-connection variant from BrassCoders' published benchmark corpus:\n\n```\ngit clone https://github.com/CopperSunDev/brasscoders\npip install brasscoders\nbrasscoders --offline scan brasscoders/docs/benchmarks/ai-code-findings-corpus/files\n```\n\nThe corpus B608 findings are in `user_lookup.py`\n\nand `bulk_insert.py`\n\n— raw `cursor.execute()`\n\ncalls with `%`\n\nformatting. Same rule, same fix. The ORM escape-hatch version produces an identical finding.\n\nBrassCoders surfaces the fix alongside the B608 finding: use SQLAlchemy's named bind parameters inside `text()`\n\nrather than string interpolation, and B608 goes silent because the query string contains no formatting operators.\n\nSQLAlchemy's `text()`\n\nfunction supports named bind parameters. The colon-prefixed syntax keeps the query string static and the value separate:\n\n``` python\nfrom sqlalchemy import text\n\ndef get_orders_by_status(session, status, min_amount):\n    stmt = text(\n        \"SELECT * FROM orders \"\n        \"WHERE status = :status AND amount >= :min_amount \"\n        \"ORDER BY created_at DESC\"\n    )\n    return session.execute(stmt, {\"status\": status, \"min_amount\": min_amount}).fetchall()\n```\n\n`:status`\n\nand `:min_amount`\n\nare placeholders. SQLAlchemy passes `{\"status\": status, \"min_amount\": min_amount}`\n\nas bind parameters — the database driver handles isolation. A value of `\"shipped' OR '1'='1\"`\n\ngoes through as the literal string `shipped' OR '1'='1`\n\n, not as SQL syntax.\n\nB608 does not fire on this pattern. The query string contains no formatting operators. The value never touches the string.\n\nFor queries that fit within the ORM's expression language, the full ORM path avoids `text()`\n\nentirely:\n\n```\nsession.query(Order).filter(\n    Order.status == status,\n    Order.amount >= min_amount\n).order_by(Order.created_at.desc()).all()\n```\n\nThis compiles to a parameterized query with no `text()`\n\ninvolved. B608 has nothing to flag. SQLAlchemy handles parameterization automatically.\n\nThe ORM path requires knowing SQLAlchemy's API. The `text()`\n\npath 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.\n\nBrassCoders version 2.0.10 catches this pattern in the offline scan, no API call required. Install via `pip install brasscoders`\n\nand run `brasscoders --offline scan`\n\non any Python project using SQLAlchemy.", "url": "https://wpnews.pro/news/sqlalchemy-orm-security-the-raw-query-escape-hatch", "canonical_source": "https://dev.to/coppersundev/sqlalchemy-orm-security-the-raw-query-escape-hatch-2ni5", "published_at": "2026-07-22 17:17:50+00:00", "updated_at": "2026-07-22 17:31:11.186295+00:00", "lang": "en", "topics": ["ai-safety", "ai-tools", "developer-tools"], "entities": ["BrassCoders", "SQLAlchemy", "Bandit"], "alternates": {"html": "https://wpnews.pro/news/sqlalchemy-orm-security-the-raw-query-escape-hatch", "markdown": "https://wpnews.pro/news/sqlalchemy-orm-security-the-raw-query-escape-hatch.md", "text": "https://wpnews.pro/news/sqlalchemy-orm-security-the-raw-query-escape-hatch.txt", "jsonld": "https://wpnews.pro/news/sqlalchemy-orm-security-the-raw-query-escape-hatch.jsonld"}}