tursodatabase/turso · error · ProgrammingError

executemany() requires a single DML (INSERT/UPDATE/DELETE/RE

Error message

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

What it means

executemany() runs an _is_dml check before preparing: it only accepts a single INSERT, UPDATE, DELETE, or REPLACE statement, matching sqlite3 semantics. SELECT, CREATE/ALTER/DROP, PRAGMA, or multi-statement SQL raise ProgrammingError immediately. sqlite3 also discards rows for DML with RETURNING under executemany, so DML-with-RETURNING is allowed but rows are thrown away.

Source

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

                    return value
                try:
                    return int(value)
                except Exception:
                    return self._lastrowid
            # Finalize anyway
            q.finalize()
        except Exception:
            # Ignore errors; lastrowid remains unchanged on failure
            pass
        return 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 bad083fafb)

Solutions

  1. Use execute() (optionally in a loop) for SELECT/DDL/PRAGMA — only batch INSERT/UPDATE/DELETE/REPLACE via executemany
  2. Split mixed scripts: run DDL with execute/executescript, then batch the DML with executemany
  3. If you need rows back per iteration, loop execute() and consume results; executemany discards RETURNING rows by design

Example fix

# before
cur.executemany("SELECT * FROM t WHERE id = ?", [(1,), (2,)])  # ProgrammingError

# after
for id_ in (1, 2):
    for row in cur.execute("SELECT * FROM t WHERE id = ?", (id_,)):
        process(row)
Defensive patterns

Strategy: validation

Validate before calling

import re

def is_dml(sql: str) -> bool:
    first = re.search(r"\S", sql)
    head = sql[first.start():].lstrip("(").split(None, 1)[0].upper() if first else ""
    return head in {"INSERT", "UPDATE", "DELETE", "REPLACE"}

if is_dml(sql):
    cur.executemany(sql, params_seq)
else:
    raise ValueError(f"executemany needs DML, got: {sql[:40]!r}")

Prevention

When it happens

Trigger: `cur.executemany("SELECT ...", [...])`, `cur.executemany("CREATE TABLE ...", [])`, executemany of a script containing multiple statements, or passing an empty/None SQL string (not DML).

Common situations: Generic batch helpers that route any SQL through executemany; migration code that batches DDL; passing a SELECT with a parameter list expecting per-row results.

Related errors


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