TL;DR: SQLAlchemy 2.0 no longer accepts a bare string as a SQL statement; it must be wrapped in text(). Change session.execute("select 1") to session.execute(text("select 1")) and import text from sqlalchemy. The wrapper also makes bound parameters explicit, which is the safer pattern the new version wants you to use.

## The error

```text
"sqlalchemy.exc.ArgumentError: Textual column expression should be explicitly declared as text('...')" after sqlalchemy 2.0 upgrade
```

## Steps to fix

1. Find the call sites: search for session.execute( and connection.execute( calls that pass a plain string literal.
   Expected: You have the list of raw-SQL statements to wrap.

2. Add the import: from sqlalchemy import text at the top of each affected module.
   Expected: The name resolves with no import error.

3. Wrap each statement: session.execute("select * from users") becomes session.execute(text("select * from users")).
   Expected: The ArgumentError is gone and the query returns rows.

4. Convert inline formatting to bound parameters: text("select * from users where id = :id") with params dict {"id": user_id}.
   Expected: Queries run with proper parameter binding instead of string interpolation.

5. Run the test suite.
   Expected: All database tests pass on SQLAlchemy 2.0.

## Use this when

- SQLAlchemy 2.0 upgrade raises Textual column expression should be explicitly declared as text()
- Raw SQL strings passed to session.execute or connection.execute break after the upgrade
- Migrating a 1.4 codebase with future=True to native 2.0 style

## Not for this skill when

- ORM query API changes (Query vs select), which are a separate migration topic
- Connection or dialect errors, which happen before any statement runs
- Alembic migration failures, which are usually env or version-table issues

## Variant phrasings

- sqlalchemy 2.0 text() required for raw sql
- Textual SQL expression should be explicitly declared
- session.execute string argument deprecated sqlalchemy

## Why it happens

In SQLAlchemy 1.x, a plain string was silently coerced to a text() construct, which blurred the line between SQL text and SQL expression objects and made injection-prone string formatting easy to miss. Version 2.0 removed the coercion so that textual SQL is always explicit. The error message is the guardrail: it forces you to declare text() and, by extension, to use bound parameters instead of f-strings.

## Edge cases

- text() statements with bound parameters need the params passed as the second argument to execute, not interpolated into the string.
- DDL strings (CREATE TABLE and friends) also need text() when run through connection.execute.
- If you relied on the old autobegin behavior around raw SQL, check transaction scoping too; 2.0 style pairs text() with explicit commit boundaries.

## Provenance

Resolved from the public thread: https://vectle.com/posts/pst_iYH8grsWoX_Ir_UlTKDtGg
