When does sqlite3.Cursor.executemany beat a loop of execute calls, and what will it refuse to run?
answer
- One compile, many binds
- Any iterable, including a generator
- Row-returning statements are refused
- The transaction sets the throughput
- A failing set leaves earlier rows applied
basics
~10 sexecutemany compiles the statement once and binds each parameter set from any iterable, including a generator, so a large batch streams without being materialized. It refuses statements that return rows, raising sqlite3.ProgrammingError.
solid answer
~50 s`sqlite3.Cursor.executemany` parses and compiles one statement, then runs it once per parameter set drawn from any iterable — a list of tuples, a list of dicts for named placeholders, or a generator, which is what lets a few hundred rows stream through without existing in memory at once. `sqlite3.Cursor.rowcount` afterwards is the total across sets, and `lastrowid` is `None`. It accepts only statements that do not return rows: give it a SELECT and it raises `sqlite3.ProgrammingError: executemany() can only execute DML statements`, because a cursor has one result set and nowhere to put many. The senior caveat is that most of the speed attributed to it is really the transaction boundary: an `execute` loop committing once at the end is close in throughput, while a per-row commit is catastrophic either way. Atomicity belongs to the transaction, not the call — a set that fails part-way leaves earlier rows applied until you roll back.
code
python · 15 linesimport sqlite3
con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE job(doc TEXT, pages INTEGER)")
rows = ((f"case-{n:03d}.pdf", n) for n in range(340))
cur = con.executemany("INSERT INTO job VALUES(?, ?)", rows)
print(cur.rowcount)
con.commit()
try:
con.executemany("SELECT doc FROM job WHERE pages = ?", [(1,), (2,)])
except sqlite3.ProgrammingError as exc:
print(exc)
con.close()go deeper
Know that executemany takes a statement plus a sequence of parameter sets and runs the statement once per set, and that it is for inserts and updates rather than queries.
Explain the mechanism — one compile, many binds, any iterable including a generator — plus rowcount as the total, lastrowid as None, and the ProgrammingError on a row-returning statement.
Show that you attribute the throughput correctly: the transaction boundary dominates, so diagnose commit frequency before call shape. Be ready to describe chunked commits and what a partially applied batch means for a retrying worker.
Own the durability and recovery story around bulk writes: what the batch size costs in lock hold time versus replay cost on failure, whether the work should be idempotent instead of transactional, and which transaction-control mechanism the codebase standardizes on.
### What executemany is `sqlite3.Cursor.executemany(sql, seq_of_parameters)` compiles one statement and runs it once per parameter set. Its second argument is any iterable of parameter containers — a list of tuples, a list of dicts for named placeholders, or a **generator**, which matters because it means a large batch never has to exist in memory at once: ```python rows = ((f"case-{n:03d}.pdf", n) for n in range(340)) cur = con.executemany("INSERT INTO job VALUES(?, ?)", rows) print(cur.rowcount) # 340 ``` The saving over a Python `for` loop calling `sqlite3.Cursor.execute` is real but narrower than people assume: the statement is parsed and compiled once instead of being looked up per iteration, and the per-row Python overhead of a bound-method call plus argument packing disappears. What it does **not** do is change transactions or batching semantics at the database level — it is still one statement execution per parameter set. ### The refusal you must know `executemany` accepts only statements that do not return rows. Give it a SELECT and it raises: ```python con.executemany("SELECT doc FROM job WHERE pages = ?", [(1,), (2,)]) # sqlite3.ProgrammingError: executemany() can only execute DML statements. ``` This is the right design — a cursor has one result set, so "run this query 340 times" has nowhere to put 340 of them — but it surprises people who expect the rows to simply be discarded, which is what older behaviour allowed. If you want per-key results, loop with `execute` and consume each result before the next call, or restructure into a single statement that takes the whole key set at once. Two related limits: `sqlite3.Cursor.lastrowid` is `None` after `executemany`, because there is no single "last row" that is meaningful for the caller, and `sqlite3.Cursor.executescript` is not the batching tool either — it runs a multi-statement script and accepts **no** parameters at all, so it is for schema setup, never for data. ### The throughput lesson: it is the transaction, not the call This is where a senior answer separates itself. Consider a document-conversion queue that persists the outcome of a 340-case regression pack after each run. The naive version calls `execute` in a loop and `sqlite3.Connection.commit` after every row, and it is slow by orders of magnitude — each commit is a durable flush. Switching to `executemany` appears to fix it, but the fix is mostly that the loop now sits inside one transaction that commits once at the end. Under the module's default legacy transaction control, `sqlite3` opens a transaction implicitly before a DML statement and holds it until you call `commit`. So a plain `execute` loop followed by a single `commit` is already within a couple of percent of `executemany`; a loop that commits per row is catastrophic no matter which call you use. Python 3.12 added `sqlite3.Connection.autocommit` (PEP 249-style control) alongside the legacy `isolation_level` mechanism, and being explicit about which one you are using is the difference between a service whose durability is designed and one whose durability is inherited. The practical shape for a batch of a few hundred rows is: one `executemany`, one `commit`, and — if the batch is very large — chunking into commits of some thousands of rows so a failure does not force replaying everything and so the write transaction does not block readers for the whole run. ### Failure semantics If a parameter set part-way through violates a constraint, the exception (`sqlite3.IntegrityError`, a subclass of `sqlite3.DatabaseError`) propagates out of the `executemany` call and the **remaining sets are not attempted**. The rows applied before the failure remain in the open transaction; only `sqlite3.Connection.rollback` discards them. So the atomicity you get is the transaction's, never the call's — an `executemany` that raises leaves a partially-applied batch unless you handle it, which for a queue means either rolling the whole batch back and retrying it, or resolving the conflict in SQL so no row raises in the first place. Also worth saying: `cur.rowcount` after a successful `executemany` is the total across all parameter sets, which makes it a usable assertion in a test ("340 rows written"), and a useless one for detecting *which* set failed. ### How to answer Lead with the mechanism — one compile, many binds, any iterable including a generator. State the refusal — DML only, `ProgrammingError` on a row-returning statement, `lastrowid` is `None`. Then make the senior point: the batching win people attribute to `executemany` is mostly the transaction boundary, and the batch's atomicity and retry story belong to the transaction, not to the call.
- A conversion queue writes the outcome of a 340-case regression pack and the write step is slow. Where do you look first?At the commit frequency, not the call shape. Under the module's default transaction control a DML statement opens a transaction implicitly and holds it until you commit, so a per-row commit means a durable flush per row — orders of magnitude slower. Move to one transaction for the batch, or chunked commits of a few thousand rows so a failure does not force replaying everything and a long write does not block readers. Switching to executemany without fixing commits changes little.
- What state is the database in when one parameter set in the middle of an executemany raises?The exception propagates out of the call, the remaining sets are not attempted, and the sets already applied stand inside the still-open transaction. Only `sqlite3.Connection.rollback` discards them, so an executemany is not atomic by itself — the transaction is. For a queue that means deciding explicitly: roll the batch back and retry it whole, or write the statement so no set can conflict in the first place.
- If executemany refuses SELECT statements, how do you run one query over many keys?Either loop with `sqlite3.Cursor.execute` and consume each result before the next call, since a cursor holds one result set at a time, or restructure into a single statement that takes the whole key set at once and returns all matches in one pass. The second is usually the right answer for a few hundred keys — and note that `sqlite3.Cursor.executescript` is not an alternative, because it accepts no parameters at all.
saying these in an interview costs you the question
- Thinks executemany commits the batch by itself
- Expects executemany to return rows from a SELECT
- Says a failed batch is rolled back automatically
- Believes it sends all parameter sets in one round trip
- Materializes a huge list when a generator would do
- Reaches for executescript to batch parameterized inserts