Batch & chunking
insertBatch() and upsertBatch() turn a big array of rows into a few multi-row
statements instead of one query per row. This page explains how those statements
are built — the shape grouping, the chunking, and the per-row placeholder
suffixing. Everything here works identically on PostgreSQL, MySQL, MariaDB and
SQLite.
Why chunk at all
Databases cap the number of bound parameters and the packet size of a single
statement. A naive “one giant INSERT” of 100k rows would blow past both. Chunking
emits one multi-row statement per fixed-size batch, keeping every statement
comfortably within limits.
| Method | Default chunkSize |
|---|---|
insertBatch |
1000 |
upsertBatch |
500 |
An empty input short-circuits to a no-op before any SQL is built.
Streaming: a generator instead of an array
Both methods take an iterable, not an array. The difference is practical: rows
are never accumulated in full — the method walks the input, buffers rows, and
sends a statement the moment the buffer fills. Peak memory therefore follows the
chunk size, not the size of the job.
Which means a million rows can be inserted without ever assembling them into an array:
$inserted = $cdo->insertBatch('users', (function () use ($csv) {
while (($line = fgetcsv($csv)) !== false) {
yield ['name' => $line[0], 'email' => $line[1]];
}
})());The same works with any generator — reading a file line by line, a cursor over another table, an external API paged request by request. An array is accepted too; it is simply already in memory by the time the call happens.
The exact memory bound
One buffer is kept per row shape (see grouping below), so the bound is
chunkSize × number of distinct shapes, not just chunkSize. With uniform rows
there is a single shape and no difference; with wildly mixed ones, either lower the
chunkSize or feed rows of the same shape consecutively.
Return values
insertBatch() returns an int — the total number of rows inserted, captured as
$inserted = $cdo->insertBatch(...). For a plain INSERT this count is reported
identically across PostgreSQL, MySQL, MariaDB and SQLite.
upsertBatch() returns void by design: an upsert’s affected-row count isn’t
comparable across drivers (MySQL/MariaDB count 2 per updated row, PostgreSQL 1,
and IGNORE / DO NOTHING count only real inserts), so there is no stable number to
return.
Placeholder suffixing
Within one chunk, every row needs its own placeholders — you can’t bind :name
twice with different values. So each row’s keys are suffixed with the row’s index
in the chunk (_0, _1, …):
rows: [{name:'A', email:'a@x'}, {name:'B', email:'b@x'}]
columns: (name, email)
values: (:name_0, :email_0), (:name_1, :email_1)
binds: :name_0 => 'A', :email_0 => 'a@x',
:name_1 => 'B', :email_1 => 'b@x'The column list is taken from the row’s keys; the values clause repeats one
suffixed tuple per row; all binds are merged into a single execute. upsertBatch
builds the same value tuples, then appends the driver-specific conflict clause —
PostgreSQL-style ON CONFLICT on PostgreSQL and SQLite, ON DUPLICATE KEY UPDATE / INSERT IGNORE on MySQL/MariaDB (see
Driver detection).
Grouping rows by shape
The generated INSERT has one column list, built from the rows in the chunk,
and every row’s suffixed placeholders are emitted into the VALUES. For the
statement to line up, all rows in a chunk must share the same columns.
You don’t have to arrange that yourself. Before chunking, both methods group the
input by each row’s column signature — the sorted set of its non-null columns.
Rows that share a signature are batched into one multi-row statement; rows of a
different shape form their own statement. This mirrors Hibernate’s
@DynamicInsert.
The grouping fixes a bug where a single chunk holding rows with different null
patterns produced a column/value count mismatch — invalid SQL like
(a, b) VALUES (1, 2), (3). Now each statement only ever sees rows of one shape,
so the column list and every value tuple always line up.
Rows are grouped by shape
Because rows are grouped by signature, they are re-ordered — all rows of the first shape are written before the next. For a plain batch insert this is harmless, but do not rely on auto-increment ids following your input array order when rows differ in shape.
Per-row NULL stripping
As with single insert(), a null value is removed from that row — the column
falls to its database default. This is exactly what defines a row’s signature: two
rows with different null columns have different shapes and land in different
groups (and therefore different statements).
A row with only NULLs is rejected
A row whose columns are all null has nothing to insert and raises a
CDOException.
Chunk size trade-offs
- Smaller chunks — less memory per statement, more round-trips, safer for very wide rows (many columns × many rows approaches the parameter cap faster).
- Larger chunks — fewer round-trips, higher throughput, more memory and more bound parameters per statement.
Tune from the defaults if your rows are unusually wide or narrow. A rough guide:
keep chunkSize × columnsPerRow well under your driver’s bound-parameter limit.
Atomicity
Each chunk is its own statement. insertBatch / upsertBatch do not open a
transaction across chunks — if the fifth chunk fails, the first four are already
committed. When you need all-or-nothing semantics across the whole batch, wrap the
call in transaction(): it opens a transaction, commits when the closure returns and
rolls back if it throws.
$cdo->transaction(function () use ($cdo, $rows) {
$cdo->insertBatch('users', $rows);
});A transaction holds everything until the end
Wrapping the whole batch means the database holds locks and uncommitted data until the last chunk. For genuinely large loads that can cost more than the ability to retry the failed piece — choose deliberately rather than by default.
Related
- Inserting records — the task-level guide
- Upserts — batch upsert usage
- Driver detection — the conflict clause per driver
- Parameter binding — how each suffixed value is typed