Skip to main content
Version: 1.6.0

Batch Operations

Four bulk methods cut round-trips when you're writing many rows at once. Each one generates the right dialect-specific SQL and chunks large inputs for you.

MethodSQL shapeAtomicity
CreateBatchINSERT … VALUES (…), (…), chunked; per-row back-fill for auto-PKs on Oracle/MySQL/SQL ServerOne statement per chunk
UpsertBatchDialect-specific bulk upsert, chunkedOne statement per chunk (N MERGE on Oracle)
UpdateBatchN UPDATE statementsSingle transaction
DeleteBatchDELETE … WHERE pk IN (…), chunkedOne statement per chunk

CreateBatch

Inserts many rows and writes each generated primary key back into the entity on every engine:

users := []*User{
{Name: "Alice", Email: "alice@example.com"},
{Name: "Bob", Email: "bob@example.com"},
{Name: "Carol", Email: "carol@example.com"},
}

if err := quark.For[User](ctx, client).CreateBatch(users); err != nil {
return err
}

fmt.Println(users[0].ID, users[1].ID, users[2].ID)
// => 1 2 3

Pass as many rows as you like. Large slices are chunked automatically so each statement stays under the engine's bind-parameter ceiling — roughly 65k on PostgreSQL, MySQL, and MariaDB, 32k on SQLite, 2100 on SQL Server. You get as few statements as the engine allows, and never a "too many parameters" error.

Chunking is not atomicity. The chunks run on whatever executor the query is bound to — the pool, or an explicit transaction — but Quark does not wrap them in an implicit one. For all-or-nothing semantics, run CreateBatch inside client.Tx:

err := client.Tx(ctx, func(tx *quark.Tx) error {
return quark.ForTx[User](ctx, tx).CreateBatch(users)
})
Auto-generated keys on Oracle, MySQL, SQL Server

These engines can't read a generated key back from a multi-row INSERT. So when the primary key is auto-generated, Quark inserts one row at a time and back-fills each key — Oracle via RETURNING … INTO, MySQL via LastInsertId, SQL Server via SCOPE_IDENTITY. The result is the same; it just costs more round-trips.

Supply the primary keys yourself and every engine keeps the chunked multi-row form.

CreateBatch runs BeforeCreate once per entity, so timestamp / default / derived-field hooks apply to batched rows just like single writes. After* hooks don't fire for batch operations — see Hooks › Limitations.

UpsertBatch

Inserts new rows and updates the ones that conflict on a chosen unique key:

records := []*Product{
{SKU: "WIDGET-A", Name: "Widget A", Price: 9.99},
{SKU: "WIDGET-B", Name: "Widget B", Price: 14.99},
}

err := quark.For[Product](ctx, client).UpsertBatch(
records,
[]string{"sku"}, // conflict column(s) — must have a UNIQUE constraint
[]string{"name", "price"}, // columns to update on conflict
)

Always pass updateCols explicitly. An empty slice is not a portable "update everything" signal — PostgreSQL and SQLite emit DO NOTHING, MySQL and MariaDB update the duplicate-key column, and the MERGE dialects infer the non-conflict columns.

When the first entity has a zero primary key, Quark omits the PK column so the database assigns it, just like CreateBatch. Each dialect gets its native form:

DialectSQL generated
PostgreSQLINSERT … ON CONFLICT (sku) DO UPDATE SET …
SQLiteINSERT … ON CONFLICT (sku) DO UPDATE SET col = excluded.col
MySQL / MariaDBINSERT … ON DUPLICATE KEY UPDATE name = VALUES(name), …
SQL ServerMERGE INTO products … WHEN MATCHED … WHEN NOT MATCHED …
OracleN individual MERGE INTO … USING (SELECT …) (identity columns aren't compatible with the multi-row MERGE shape)

Like CreateBatch, UpsertBatch runs BeforeCreate per entity (insert-prep); on conflict the updateCols win.

UpdateBatch

Updates many rows by primary key. Each entity gets a partial update — zero-value fields are skipped, exactly like Update — and all updates run in a single transaction: if any row fails, the whole batch rolls back.

users, _ := quark.For[User](ctx, client).Where("active", "=", true).List()

ptrs := make([]*User, len(users))
for i := range users {
users[i].Score += 100
ptrs[i] = &users[i]
}

if err := quark.For[User](ctx, client).UpdateBatch(ptrs); err != nil {
return err // every change rolled back
}

To write an explicit zero value, use UpdateMap on the individual row.

DeleteBatch

Hard-deletes rows by primary key, chunked to the engine's IN-list limit (Oracle caps IN at 1000 elements, so 2500 IDs become three statements: 1000 + 1000 + 500). IDs that don't exist aren't an error — affected is just the count actually removed:

affected, err := quark.For[User](ctx, client).DeleteBatch([]any{10, 11, 12})
fmt.Println(affected)
// => 1 (only one of those IDs still existed)

missing, _ := quark.For[User](ctx, client).DeleteBatch([]any{99999})
fmt.Println(missing)
// => 0

DeleteBatch is always a hard delete. For a bulk soft delete, use UpdateMap to set deleted_at under an explicit predicate. WHERE-based deletes bypass lifecycle hooks — load the rows and loop through Delete if you need them.

A data-sync workflow

The four together make a typical sync readable end to end:

// 1. Insert new records.
if err := quark.For[Product](ctx, client).CreateBatch(newProducts); err != nil {
return err
}
// 2. Upsert the catalog feed (insert-or-update by SKU).
if err := quark.For[Product](ctx, client).UpsertBatch(
catalogFeed, []string{"sku"}, []string{"name", "price", "stock"},
); err != nil {
return err
}
// 3. Re-price in memory, then bulk-update.
for _, p := range productsToReprice {
p.Price = newPrice(p.SKU)
}
if err := quark.For[Product](ctx, client).UpdateBatch(productsToReprice); err != nil {
return err
}
// 4. Remove discontinued SKUs.
if _, err := quark.For[Product](ctx, client).DeleteBatch(discontinuedIDs); err != nil {
return err
}

For a sync that must be all-or-nothing, wrap the whole thing in client.Tx and use ForTx.