Skip to main content
Version: 1.2.2

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

Large slices are chunked automatically so each statement stays under the engine's own bind-parameter ceiling — ~65k on PostgreSQL/MySQL/MariaDB, ~32k on SQLite, ~2100 on SQL Server — so you can pass tens of thousands of rows without a "too many parameters" error, in as few statements as the engine allows. Chunks run on whatever executor the query is bound to (the pool, or an explicit transaction), but they are not wrapped in an implicit transaction — 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) — same result, just more round-trips. Supply the primary keys yourself and every engine keeps the faster 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
)

Pass updateCols explicitly: an empty slice isn't a portable "update everything" signal (PostgreSQL/SQLite emit DO NOTHING, MySQL/MariaDB update the duplicate-key column, MERGE dialects infer 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()
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.