skip to content

Why is a multi-row INSERT ... VALUES faster than 50,000 single-row INSERT statements?

level: juniorimportance: should knowfreq 60%

answer

  1. The waiting, not the writing, dominates
  2. One round trip instead of a thousand
  3. A statement is all-or-nothing
  4. Bigger batches hit parameter and size limits

basics

~20 s

One statement carrying 1,000 rows costs one round trip, one parse and one execution; 1,000 separate INSERTs cost all of that a thousand times. The per-row writing and index maintenance is unchanged — only the per-statement overhead disappears.

solid answer

~50 s

Sending rows one statement at a time makes the client wait for a server response before it can send the next row, so the job's runtime is dominated by round-trip latency rather than by the inserts themselves. `INSERT INTO t (a, b) VALUES (1,'x'), (2,'y'), (3,'z')` folds many rows into one statement: one round trip, one parse, one execution, and — inside a single transaction — one commit. In practice you batch in groups of a few hundred to a few thousand rows rather than one huge statement, because a statement carrying tens of thousands of rows runs into statement-size and parameter limits, uses more server memory, and makes failure expensive: the statement is atomic, so one constraint violation rolls back the whole batch. What batching does not change is the real work — every row is still written and every index still maintained.

code

sql · 8 lines
sql
-- One statement per row: 50,000 round trips
INSERT INTO measurements (sensor_id, taken_at, value) VALUES (1, TIMESTAMP '2024-05-01 10:00:00', 21.4);

-- Batched: one round trip per group of rows
INSERT INTO measurements (sensor_id, taken_at, value)
VALUES (1, TIMESTAMP '2024-05-01 10:00:00', 21.4),
       (1, TIMESTAMP '2024-05-01 10:01:00', 21.6),
       (2, TIMESTAMP '2024-05-01 10:00:00', 18.9);

go deeper

for a junior

Know the multi-row VALUES form and be able to say why it is faster: one statement means one round trip and one parse instead of thousands.

for a middle

Explain the batch-size tradeoff — statement and parameter limits, server memory, and the all-or-nothing failure of a single statement — and know that per-row index work is unaffected.

for a senior

Show how a loader stays re-runnable: identifiable batches, a unique key so a retry conflicts rather than duplicates, and INSERT ... SELECT whenever the source rows are already in the database.

for a principal

Frame ingestion throughput as a budget: latency per round trip, rows per statement, indexes per table, and where bulk-load tooling beats hand-written statements entirely.

## Where the time actually goes Loading 50,000 rows with 50,000 statements spends most of its wall-clock time not inserting. Each statement is sent to the server, and the client waits for the response before sending the next. That wait is network latency, and it is paid 50,000 times. On a 1 ms link that is 50 seconds; over a slower network link it can be many minutes. Add per-statement parsing and, in autocommit mode, a durable commit per row. A multi-row `INSERT` collapses those per-statement costs: ```sql INSERT INTO measurements (sensor_id, taken_at, value) VALUES (1, TIMESTAMP '2024-05-01 10:00:00', 21.4), (1, TIMESTAMP '2024-05-01 10:01:00', 21.6), (2, TIMESTAMP '2024-05-01 10:00:00', 18.9); ``` One statement, one round trip, one parse, one execution, one commit. Fifty statements of 1,000 rows each replace 50,000 statements, and the round-trip bill drops by three orders of magnitude. ## What batching does not speed up The rows still have to be written, and every index on the table still has to be maintained for every row. Constraint checks still run for every row. If a load is slow because the table carries many indexes, batching the statements will not fix that — batching removes *statement* overhead, not *row* work. Being explicit about this distinction is what separates a candidate who has reasoned about it from one repeating advice. ## Choosing a batch size Bigger batches keep saving round trips, but with diminishing returns and rising costs: - **Statement limits.** Engines cap statement text size and the number of bind parameters a statement may carry. A 50,000-row batch with five columns is 250,000 parameters and will run into a limit somewhere. - **Memory.** The whole statement must be received, parsed and held server-side before execution. - **Failure blast radius.** A single SQL statement is atomic: if row 4,712 violates a unique or check constraint, the entire statement fails and none of its rows are inserted. With 1,000-row batches you retry 1,000 rows; with a 50,000-row batch you retry everything, and finding the offending row is harder. - **Transaction length.** Batches are usually committed per batch, which keeps each unit of work short. A few hundred to a few thousand rows per statement is the usual working range; the honest answer is to measure on your own connection and schema, because the optimum depends on latency and row width. ## The prepared-statement alternative Many client drivers can execute one prepared statement against many parameter sets in a single exchange with the server. That achieves the same round-trip saving with a single, unchanging statement text — which is often preferable, because a multi-row `VALUES` list produces a *different* statement text for every distinct row count. Both approaches are batching; they differ in how the rows travel, not in the principle. ## INSERT ... SELECT: batching without a client at all When the rows already exist inside the database, do not fetch and re-insert them. `INSERT INTO target (a, b) SELECT a, b FROM source WHERE ...` moves the data entirely server-side: no rows cross the network in either direction and there is exactly one statement, regardless of how many rows qualify. This is the fastest form of batching available, and it is what you should reach for whenever the source of the rows is a query rather than the application. ## Partial failure and retry Because each statement is atomic, a load built from batches needs to be re-runnable. Two habits make that easy: make each batch's rows identifiable (so a retry knows what to redo), and design the target so a repeated insert conflicts loudly rather than silently duplicating — a unique key on the natural identifier. Engines offer opt-in variants that skip or merge conflicting rows, but those are dialect-specific extensions; the portable base is "the statement inserts all its rows or none of them". ## The summary sentence Batching converts N statements into N/k statements. It removes latency and per-statement overhead, leaves per-row storage work untouched, and trades a larger blast radius on failure for the saving — which is why the batch size is a tuning decision rather than "as large as possible".

  • What happens to the other 999 rows when one row in a 1,000-row INSERT violates a constraint?
    Nothing is inserted. A single SQL statement is atomic: the constraint violation fails the whole statement, so the entire batch is undone. That is why batch size is also a blast-radius decision, and why a re-runnable loader has to know which batch failed rather than which row.
  • If the driver can execute one prepared statement against many parameter sets in one exchange, is a multi-row VALUES list still worth it?
    Both cut round trips, so either is a big win over one statement per row. The prepared-statement form keeps one unchanging statement text, while a multi-row VALUES produces different text for each distinct row count. Pick whichever your driver does well and measure; the principle is identical.
  • The rows you want to insert already exist in another table. What is the best batching form?
    INSERT INTO target (cols) SELECT cols FROM source WHERE ... — one statement, no rows crossing the network in either direction, and no client-side loop at all. Fetching rows into the application only to send them back is pure wasted transfer.

saying these in an interview costs you the question

  • Thinks the database inserts rows faster inside one statement
  • Believes a failing row is skipped and the rest inserted
  • Assumes bigger batches are always better
  • Fetches rows into the app to re-insert them elsewhere
  • Expects batching to offset a heavily indexed table

context