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
- Use execute() once per statement for SELECT, DDL, or scripts
- Keep exactly one DML statement in the executemany() string and put the variety in the parameter rows
- 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
- Use executemany only for one parameterized DML statement
- Route DDL and SELECT through execute() by design, not by exception
- Remember WITH-prefixed DML is rejected here; keep CTEs out of executemany strings
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
- Cannot operate on a closed cursor
- Named parameters are not supported; use positional…
- query timeout must be non-negative
- size must be non-negative
- Batch execution failed
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)