Docs

Bulk writes

How to insert or update many rows efficiently, why Promise.all() with many run() calls is not a bulk-write API, and when to reach for runBatch() versus runMany().

The anti-pattern: fanning out run() calls

// Don't do this for hundreds/thousands of rows.
await Promise.all(rows.map((r) => CapacitorSqlite.run({ database: 'myapp', statement: '...', values: r })));

An explicit transaction removes repeated SQLite commit/fsync cost, but it does not remove the JS→native bridge round trip for every individually awaited run(). Promise.all() over thousands of run() calls still creates one bridge request, one native queue entry, one result object, and one JavaScript callback per row — it is a bridge fan-out benchmark, not a bulk-insert primitive. It also means mixed read/write traffic against the same database is serialized by design (see Platform notes), so a large write fan-out can delay reads submitted at the same time.

For hundreds or thousands of rows, send them through runBatch() (mixed SQL) or runMany() (one repeated statement) instead.

runBatch(): mixed SQL in one call

Executes multiple parameterized statements in a single native call — for batches where the SQL differs between items.

await CapacitorSqlite.runBatch({
  database: 'myapp',
  set: [
    { statement: 'INSERT INTO users (name) VALUES (?)', values: ['Bob'] },
    { statement: 'INSERT INTO users (name) VALUES (?)', values: ['Carol'] },
  ],
});

Wrapped in a single transaction by default (transaction: true). With that default, runBatch() validates the complete input set before the first write; Android and iOS reuse one prepared statement for repeated identical SQL and read the connection change counter once around the whole batch, avoiding per-row prepare/metadata overhead without changing atomicity or the returned changes. transaction: false keeps prior successful statements committed if a later one fails, and is required when calling runBatch() from inside an already-active manual transaction (see Transactions). lastInsertId on the aggregate result is always 0 — use run() when the inserted row's id is needed.

runMany(): one statement, many value sets

Executes one parameterized statement once per value set, in a single plugin call. Prefer this over runBatch() when every operation shares the same SQL — the statement text is transported and classified only once instead of once per item.

await CapacitorSqlite.runMany({
  database: 'myapp',
  statement: 'INSERT INTO users (name) VALUES (?)',
  values: [['Bob'], ['Carol']],
});

Android, iOS, and Electron reuse one prepared statement for the whole loop; Web's sqlite-wasm Worker1 doesn't expose a persistent prepared-statement handle but still benefits from the compact single-SQL request shape. All value sets are validated before the first write, and the operation is atomic by default — pass transaction: false to preserve earlier successful executions if a later one fails.

lastInsertId on the aggregate result is always 0. Set returnResults: true only when each execution's changes/lastInsertId is actually needed — omitting it minimizes bridge output and maximizes throughput.

Electron compatibility note: an app whose generated main/preload plugin registry predates runMany() still works — the JS wrapper falls back to an equivalent runBatch()/run()-loop implementation automatically — but regenerate that registry after upgrading to get the faster native repeated-statement path.

runBatch() vs runMany() vs a manual transaction

Use When
runMany() Every operation uses the same SQL statement.
runBatch() Operations use different SQL statements.
run() inside beginTransaction()/commitTransaction() You need per-row results interleaved with other application logic, or need to combine writes across multiple statements/tables inside one larger unit of work.

Web/OPFS-specific durability cost

On Web, each successful autocommit run() write includes a browser OPFS durability barrier — this is intentional durability, not SQL planning time or general plugin overhead. Looping individual autocommit run() calls (or fanning them out with Promise.all()) multiplies that per-row durable-commit cost. Group writes with runMany(), runBatch(), or an explicit transaction to pay that barrier once for the whole group instead of once per row.

Indexes still matter

The plugin does not create indexes automatically or rewrite application SQL. A filtered query such as WHERE id > ? ORDER BY id DESC LIMIT 100 scans and sorts the whole table without a matching index, regardless of batching. Add an index on the filter/order columns (CREATE INDEX ... ON table(id)) when this pattern is latency-sensitive — EXPLAIN QUERY PLAN reports the unindexed cost as SCAN ... and USE TEMP B-TREE FOR ORDER BY.

Next step

Platform notes documents the underlying serialization model these guidelines are built on, and the full set of cross-platform caveats.

Last updated on July 17, 2026