sqlalchemy/alembic · error · ValueError

String or text() construct expected

Error message

String or text() construct expected

What it means

Raised as ValueError in _textual_index_column (sqla_compat.py:389) when the text_ argument is none of: a plain str, a sqlalchemy.text() TextClause, a _textual_index_element, or a ColumnElement. The function exists to coerce the textual index-expression argument of op.create_index / index definitions into a proper column; anything else is rejected because it cannot be mapped to a column expression.

Source

Thrown at alembic/util/sqla_compat.py:389

        collection.remove(to_remove)


def _textual_index_column(
    table: Table, text_: str | TextClause | ColumnElement[Any]
) -> ColumnElement[Any] | Column[Any]:
    """a workaround for the Index construct's severe lack of flexibility"""
    if isinstance(text_, str):
        c = Column(text_, sqltypes.NULLTYPE)
        table.append_column(c)
        return c
    elif isinstance(text_, TextClause):
        return _textual_index_element(table, text_)
    elif isinstance(text_, _textual_index_element):
        return _textual_index_column(table, text_.text)
    elif isinstance(text_, sql.ColumnElement):
        return _copy_expression(text_, table)
    else:
        raise ValueError("String or text() construct expected")


def _copy_expression(expression: _CE, target_table: Table) -> _CE:
    def replace(col):
        if (
            isinstance(col, Column)
            and col.table is not None
            and col.table is not target_table
        ):
            if col.name in target_table.c:
                return target_table.c[col.name]
            else:
                c = _copy(col)
                target_table.append_column(c)
                return c
        else:
            return None

View on GitHub (pinned to 44fb345033)

Solutions

  1. Wrap raw SQL in sqlalchemy.text('...') before passing it as an index column.
  2. Pass actual Column / ColumnElement objects (e.g. table.c.my_col) instead of bare values.
  3. Ensure every entry in the create_index columns list is a str, TextClause, or ColumnElement.
  4. If using a computed/functional index, use text('lower(col)') or func.lower(table.c.col).

Example fix

# before
from alembic import op
op.create_index('ix', 't', [123])  # int triggers ValueError

# after
import sqlalchemy as sa
op.create_index('ix', 't', [sa.text('lower(name)')])
Defensive patterns

Strategy: type-guard

Validate before calling

import sqlalchemy as sa
from sqlalchemy.sql.elements import ColumnElement
from sqlalchemy.sql.expression import TextClause

def coerce_index_column(table, col):
    if isinstance(col, (str, TextClause, ColumnElement)):
        return col
    if isinstance(col, int):
        return sa.text(str(col))
    raise TypeError(f'index column must be str/text()/ColumnElement, got {type(col).__name__}')

Type guard

import sqlalchemy as sa
from sqlalchemy.sql.elements import ColumnElement
from sqlalchemy.sql.expression import TextClause
def is_valid_index_column(c) -> bool:
    return isinstance(c, (str, TextClause, ColumnElement))

Try / catch

from alembic.util.sqla_compat import _textual_index_column
try:
    col = _textual_index_column(table, value)
except ValueError:
    col = sa.text(str(value))  # coerce to a text expression

Prevention

When it happens

Trigger: Passing an int, float, dict, list, or a raw SQL fragment object that isn't a TextClause to the textual index column path; calling _textual_index_column directly with an unsupported type; an autogenerate render path that emits a non-expression value into an index column spec.

Common situations: Writing op.create_index with a columns list that accidentally includes a non-string/non-expression value; mixing old-style string SQL fragments with expression objects; a custom type whose __repr__ is mistakenly passed instead of the object.

Related errors


AI-assisted analysis of sqlalchemy/alembic@44fb345033 (2026-08-04). Data as JSON: /data/errors/cbc01096bea50264.json. Report an issue: GitHub.