Safeguard
Security Guides•

Is SQLAlchemy text() safe? Bound parameters versus f-strings

text() is safe when values go in as bound parameters with :name placeholders. Building the SQL string with f-strings or format brings SQL injection back.

Safeguard Security Team
4 min read

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.

NeedSafe approach
User supplied valueBound parameter
User chooses a sort columnMap allowed keys to column objects, reject others
User chooses sort directionAllow only asc or desc from a fixed set
Dynamic table nameChoose from a fixed allow list in code
IN listUse 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

  1. Search for text(f", text("... %, .format( near text(), and string concatenation near execute().
  2. For each hit, confirm every user influenced value is bound.
  3. Check identifiers: any column, table or order clause built from input needs an allow list.
  4. Remember that second order injection exists: values read back from the database and inserted into SQL later are still untrusted.
  5. 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.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.