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.
| Method | SQL shape | Atomicity |
|---|---|---|
CreateBatch | INSERT … VALUES (…), (…), chunked; per-row back-fill for auto-PKs on Oracle/MySQL/SQL Server | One statement per chunk |
UpsertBatch | Dialect-specific bulk upsert, chunked | One statement per chunk (N MERGE on Oracle) |
UpdateBatch | N UPDATE statements | Single transaction |
DeleteBatch | DELETE … WHERE pk IN (…), chunked | One 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)
})
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:
| Dialect | SQL generated |
|---|---|
| PostgreSQL | INSERT … ON CONFLICT (sku) DO UPDATE SET … |
| SQLite | INSERT … ON CONFLICT (sku) DO UPDATE SET col = excluded.col |
| MySQL / MariaDB | INSERT … ON DUPLICATE KEY UPDATE name = VALUES(name), … |
| SQL Server | MERGE INTO products … WHEN MATCHED … WHEN NOT MATCHED … |
| Oracle | N 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.
When the ids arrive as a typed slice, DeleteBatchOf performs the []any
conversion internally (a package-level function, like WhereInOf):
ids := []int64{10, 11, 12}
affected, err := quark.DeleteBatchOf(quark.For[User](ctx, client), ids)
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.