tursodatabase/turso · error · ProgrammingError

executemany() requires a single DML…

Error message

executemany() requires a single DML (INSERT/UPDATE/DELETE/REPLACE) statement

What it means

Cursor.executemany() only accepts a single DML statement. Before preparing anything, it runs _is_dml(sql), which reads the first SQL keyword (skipping leading comments and whitespace) and requires INSERT, UPDATE, DELETE, or REPLACE. Anything else raises ProgrammingError, matching stdlib sqlite3 semantics. Note the implementation conservatively rejects WITH-prefixed statements too, since only the literal first keyword is checked.

Solutions

  1. Use execute() once per statement for SELECT, DDL, or scripts
  2. Keep exactly one DML statement in the executemany() string and put the variety in the parameter rows
  3. For WITH-prefixed DML, either drop the CTE or run it through execute() in a loop

Example fix

# before
cur.executemany("INSERT INTO t VALUES (1); INSERT INTO t VALUES (2)", [()])

# after
cur.executemany("INSERT INTO t VALUES (?)", [(1,), (2,)])
Defensive patterns

Strategy: validation

Validate before calling

import re

_DML_RE = re.compile(r"\s*(INSERT|UPDATE|DELETE|REPLACE)\b", re.IGNORECASE)

def is_single_dml(sql: str) -> bool:
    return bool(_DML_RE.match(sql)) and ";" not in sql.rstrip().rstrip(";")

if is_single_dml(sql):
    cur.executemany(sql, rows)
else:
    for stmt in sql.split(";"):
        cur.execute(stmt)

Try / catch

from turso.lib import ProgrammingError

try:
    cur.executemany(sql, rows)
except ProgrammingError as e:
    if "executemany()" in str(e):
        cur.execute(sql)  # not DML: run once without parameter rows
    else:
        raise

Prevention

When it happens

Trigger: cur.executemany('SELECT ...', rows); executemany with DDL such as CREATE TABLE or PRAGMA; executemany with several statements joined by semicolons in one string; executemany('WITH cte AS (...) INSERT ...') - the WITH prefix fails the keyword check even though it is DML.

Common situations: Bulk-load scripts that batch many INSERTs into one semicolon-joined string; trying to create schema rows and schema objects in the same call; running one SELECT with varying parameters; CTE-based bulk inserts.

Related errors


AI-assisted analysis of tursodatabase/turso@c1e5928725 (2026-08-20). Data as JSON: /api/errors/b42a39809543239d. Report an issue: GitHub.

Appendix: source

Thrown at bindings/python/turso/lib.py:985

                pass
            raise _map_turso_exception(exc)

        return self

    def _fetch_last_insert_rowid_if_needed(self, sql: str, rows_changed: int) -> Optional[int]:
        if rows_changed <= 0 or not _is_insert_or_replace(sql):
            return self._lastrowid
        rowid = self._connection._last_insert_rowid()
        # lastrowid remains unchanged when it cannot be determined.
        return rowid if rowid is not None else self._lastrowid

    def executemany(self, sql: str, seq_of_parameters: Iterable[Sequence[Any] | Mapping[str, Any]]) -> "Cursor":
        self._ensure_open()
        self._reset_last_result()

        # executemany only accepts DML; enforce this to match sqlite3 semantics
        if not _is_dml(sql):
            raise ProgrammingError("executemany() requires a single DML (INSERT/UPDATE/DELETE/REPLACE) statement")

        # Implement legacy implicit transaction: same as execute()
        self._maybe_implicit_begin(sql)

        prepared = self._prepare_single_statement(sql)
        stmt = prepared.stmt
        try:
            # For executemany, discard any rows produced (even if RETURNING was used)
            # Therefore we ALWAYS use execute() path per-iteration.
            for parameters in seq_of_parameters:
                # Reset previous bindings and program memory before reusing
                stmt.reset()
                self._bind_params(stmt, parameters)
                result = _run_execute_with_io(stmt, self._connection.extra_io)
                # rowcount is "the number of modified rows" for the LAST executed statement only
                self._rowcount = int(result.rows_changed) + (self._rowcount if self._rowcount != -1 else 0)
            # After loop, finalize statement
            stmt.finalize()

View on GitHub (pinned to c1e5928725)