Skip to content

Writing an Alembic data migration whose raw SQL contains POSIX regexes (regexp_replace/substring patterns like (?:x|twitter).com and a LIKE @% clause) for a Postgres backfill. Running it via op.execu

Writing an Alembic data migration whose raw SQL contains POSIX regexes (regexp_replace/substring patterns like (?:x|twitter).com and a LIKE @% clause) for a Postgres backfill. Running it via op.execute() failed with: sqlalchemy.exc.StatementError: (sqlalchemy.exc.InvalidRequestError) A value is required for bind parameter x — the rendered SQL showed the regex rewritten to (?%(x)s|twitter), i.e. part of the pattern had been consumed as a parameter marker. Nothing in the migration passed parameters at all, so the error looked impossible. Switching the statement to run at the driver level then produced a second baffling error instead: TypeError: sqlalchemy.cyextension.immutabledict.immutabledict is not a sequence, raised from psycopg2 cursor.execute, with no hint that the SQL text itself was the problem. Alembic docs for op.execute do not mention any escaping requirements for literal colons or percent signs in SQL strings.

1 solution
ranked by outcome — not votes
Accepted

Two separate quoting layers bite here (SQLAlchemy 2.x, Alembic 1.x, psycopg2):

1. op.execute(string) wraps the string in sa.text(), and text() treats every :name sequence as a bind parameter — including the :x inside a regex non-capture group (?:x|...). Hence A value is required for bind parameter x.

Fixes, pick one:

  • Run the statement at the driver level, bypassing text() parsing entirely:
    bind = op.get_bind()
    bind.exec_driver_sql(sql)
  • Or escape the colon for text() with a backslash: (?\:x|twitter).
  • Or (Postgres regex specifics) avoid ?: by using capturing groups with regexp_replace(..., backref) instead of substring() (which only returns the first parenthesized group).

2. Once at the driver level, psycopg2 applies %-style paramstyle handling: a bare % in the SQL (e.g. LIKE with @%) makes cursor.execute attempt format interpolation against the (empty, dict-like) parameter object SQLAlchemy passes, producing TypeError: immutabledict is not a sequence. Escape as %%, or avoid % entirely — e.g. replace the LIKE with starts_with(handle, char) (Postgres 11+).

Bonus trap in the same migration shape: alembic_version.version_num is VARCHAR(32), so a descriptive date-prefixed revision id over 32 chars fails at stamp time with value too long for type character varying(32); keep revision ids short.

Working pattern for a regex-heavy data migration:

def upgrade() -> None:
    bind = op.get_bind()
    for statement in UPGRADE_SQL.split(";"):
        if statement.strip():
            bind.exec_driver_sql(statement)

with UPGRADE_SQL containing no bare % and no reliance on text() bind params.