text() is safe when you pass values as bound parameters using colon placeholders and a parameters dictionary. It is not safe when you build the SQL string yourself with an f-string, format or concatenation. SQLAlchemy's tutorial says a parameter should always be used, because it is the way to avoid SQL injection with untrusted data.
Two ways to write the same query
Unsafe:
name = request.args["name"]
conn.execute(text(f"SELECT * FROM users WHERE name = '{name}'"))
Safe:
name = request.args["name"]
conn.execute(
text("SELECT * FROM users WHERE name = :name"),
{"name": name},
)
In the first version, the user's input becomes part of the SQL text. A value such as a quote followed by SQL syntax changes what the query does. In the second, the SQL text is fixed and the value travels separately to the database driver.
What SQLAlchemy says
The SQLAlchemy tutorial introduces text() for writing textual SQL and notes that it is the exception rather than the rule in day to day use. When it discusses sending parameters, it says that a parameter should always be used rather than rendering a value into the SQL string directly. It calls out SQL injection as the famous reason, and adds that bound parameters also let the dialect or DBAPI handle the input correctly for the backend.
It also states that outside plain textual SQL, the Core Expression API otherwise ensures that Python literal values are passed as bound parameters where appropriate. So using select(), where(), and the ORM query constructs gets you this for free.
What parameters cannot do
Bound parameters substitute values. They do not substitute identifiers. You cannot bind a table name, a column name or a sort direction. Developers hit this and reach for string formatting, which reintroduces the problem.
| Need | Safe approach |
|---|---|
| User supplied value | Bound parameter |
| User chooses a sort column | Map allowed keys to column objects, reject others |
| User chooses sort direction | Allow only asc or desc from a fixed set |
| Dynamic table name | Choose from a fixed allow list in code |
| IN list | Use the expanding bind parameter feature, not manual joining |
For the sort example:
SORTABLE = {"name": User.name, "created": User.created_at}
column = SORTABLE.get(request.args.get("sort"), User.name)
stmt = select(User).order_by(column)
Nothing user controlled reaches the SQL text.
Review checklist
- Search for
text(f",text("... %,.format(near text(), and string concatenation near execute(). - For each hit, confirm every user influenced value is bound.
- Check identifiers: any column, table or order clause built from input needs an allow list.
- Remember that second order injection exists: values read back from the database and inserted into SQL later are still untrusted.
- Run a SAST scan in CI so new string built queries fail review.
Try it yourself
Click through it at your own pace, or open it full screen. Look for how string built queries are reported as injection findings.
Quick check
Does text() itself make queries unsafe?
No. text() with bound parameters is fine. The risk comes from building the string with untrusted input.
Can I bind a column name as a parameter?
No. Parameters carry values, not identifiers. Use an allow list that maps input to known columns.
Safeguard's SAST can flag string built SQL, and policy gates can stop a pull request that adds one. Try a free scan.
Sources
Last reviewed: 19 October 2026.