tursodatabase/turso · error · ProgrammingError

executemany() requires a single DML statement

Error message

executemany() requires a single DML statement

What it means

ProgrammingError raised by Cursor.executemany() (connection.py:252-259) when the SQL text is not classified as DML by _is_dml() (dbapi.py:88-93). The classifier takes the first keyword of the statement, skipping whitespace and -- and /* */ comments, and accepts only INSERT, UPDATE, DELETE, or REPLACE — matching sqlite3, which restricts executemany to a single DML statement. Two non-obvious rejections: multi-statement strings ('INSERT ...; INSERT ...') fail because only the first statement counts, and WITH-prefixed DML ('WITH x AS (...) INSERT ...') is rejected on purpose to avoid false positives.

Source

Thrown at serverless/python/turso_serverless/connection.py:259

            self._rowcount = -1
        else:
            self._description = None
            self._rows = []
            self._rowcount = result.affected_rows

        if result.last_insert_rowid is not None and _is_insert_or_replace(sql):
            self._lastrowid = result.last_insert_rowid

        return self

    def executemany(self, sql: str, seq_of_parameters: Iterable[Sequence[Any] | Mapping[str, Any]]) -> Cursor:
        self._ensure_open()
        self._rows = []
        self._row_index = 0
        self._description = None

        if not _is_dml(sql):
            raise ProgrammingError("executemany() requires a single DML statement")

        self._connection._maybe_implicit_begin(sql)

        total = 0
        for parameters in seq_of_parameters:
            args, named_args = self._convert_params(parameters)
            result = self._connection._execute_stmt(
                sql, params=args, named_params=named_args, want_rows=False,
            )
            total += result.affected_rows

        self._rowcount = total
        return self

    def executescript(self, sql_script: str) -> Cursor:
        """Execute multiple statements via the pipeline sequence endpoint."""
        self._ensure_open()
        self._rows = []

View on GitHub (pinned to bad083fafb)

Solutions

  1. Use execute() per row or executescript() for multi-statement or non-DML SQL
  2. Pass exactly one INSERT/UPDATE/DELETE/REPLACE statement to executemany
  3. Rewrite CTE DML as a plain statement (e.g. 'INSERT INTO t (x) VALUES (?)') so the classifier accepts it

Example fix

// before
cur.executemany("INSERT INTO t VALUES (?); SELECT changes()", rows)

// after
cur.executemany("INSERT INTO t VALUES (?)", rows)
Defensive patterns

Strategy: validation

Validate before calling

import re

_DML_RE = re.compile(r"^(?:--[^\n]*\n|/\*.*?\*/|\s)*(INSERT|UPDATE|DELETE|REPLACE)\b", re.IGNORECASE | re.DOTALL)


def is_executemany_safe(sql: str) -> bool:
    """Mirror of the driver's first-keyword DML check (WITH is rejected)."""
    return bool(_DML_RE.match(sql))


if not is_executemany_safe(sql):
    raise ValueError(f"executemany needs a single INSERT/UPDATE/DELETE/REPLACE: {sql[:40]!r}")

Try / catch

from turso_serverless.dbapi import ProgrammingError

try:
    cur.executemany(sql, rows)
except ProgrammingError as e:
    if "requires a single DML statement" not in str(e):
        raise
    if ";" in sql.strip().rstrip(";"):
        conn.executescript(sql)          # multi-statement script
    else:
        for row in rows:                 # non-DML: run per row
            cur.execute(sql, row)

Prevention

When it happens

Trigger: cur.executemany("SELECT ...", rows); executemany with DDL (CREATE/ALTER); a batch string containing two statements separated by ';'; an upsert written as 'WITH ... INSERT ... SELECT'.

Common situations: Porting code that concatenates statements into one batch string; trying to seed schema with executemany instead of executescript(); CTE-based bulk upserts moved from execute() to executemany() for speed.

Related errors


AI-assisted analysis of tursodatabase/turso@bad083fafb (2026-08-16). Data as JSON: /api/errors/5bb40346ba8f73c1. Report an issue: GitHub.