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
- Use execute() (optionally in a loop) for SELECT/DDL/PRAGMA — only batch INSERT/UPDATE/DELETE/REPLACE via executemany
- Split mixed scripts: run DDL with execute/executescript, then batch the DML with executemany
- 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
- Batch only INSERT/UPDATE/DELETE/REPLACE with executemany — everything else goes through execute/executescript
- Remember executemany discards RETURNING rows by design; loop execute() when you need results
- Guard generic batch helpers with an _is_dml-style check so misuse fails with your own message
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
- executemany() requires a single DML statement
- no SQL statements to execute
- You can only execute one statement at a time
- Cannot operate on a closed cursor
- Named parameters are not supported; use positional parameter
AI-assisted analysis of tursodatabase/turso@bad083fafb (2026-08-16).
Data as JSON: /api/errors/b42a39809543239d.
Report an issue: GitHub.