Skip to main content
Version: 0.7.0

Batch Operations

QUARK provides four bulk operations that reduce round-trips and improve throughput when working with multiple records. All batch methods handle dialect-specific SQL generation automatically.

MethodSQL shapeAtomicity
CreateBatchSingle INSERT … VALUES (…), (…) except Oracle identity fallbackSingle statement for most dialects
UpsertBatchDialect-specific bulk upsertSingle statement for most dialects, N MERGE for Oracle
UpdateBatchN UPDATE statementsSingle transaction
DeleteBatchDELETE … WHERE pk IN (…) chunkedChunked statements

CreateBatch

Inserts multiple rows in a single INSERT statement. When the dialect supports RETURNING, primary keys are written back to each entity pointer.

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
}
// users[0].ID, users[1].ID, users[2].ID are now populated

Oracle limitation: multi-row insert forms conflict with GENERATED ALWAYS AS IDENTITY columns, so Oracle falls back to individual single-row inserts automatically.


UpsertBatch

Inserts rows that do not exist yet, and updates rows that conflict on a given set of columns — in a single batch statement where the dialect allows it.

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. In the current dialect implementations, an empty updateCols slice is not a portable “update everything” signal: PostgreSQL and SQLite generate DO NOTHING, MySQL/MariaDB update the duplicate-key column, and MERGE-based dialects can infer non-conflict columns.

Auto-increment PK handling

When the first entity in the slice has a zero primary key (e.g. ID == 0), QUARK omits the PK column from the INSERT clause so the database assigns it — identical to how CreateBatch works.

Dialect strategies

DialectSQL generated
PostgreSQLINSERT INTO … VALUES (…), (…) ON CONFLICT (sku) DO UPDATE SET …
SQLiteINSERT INTO … VALUES (…), (…) ON CONFLICT (sku) DO UPDATE SET col = excluded.col
MySQL / MariaDBINSERT INTO … VALUES (…), (…) ON DUPLICATE KEY UPDATE name = VALUES(name), …
MSSQLMERGE INTO products AS target USING (VALUES (…), (…)) AS src(…) ON (…) WHEN MATCHED … WHEN NOT MATCHED …
OracleN individual MERGE INTO … USING (SELECT …) … statements

:::info Oracle note Oracle identity columns are not compatible with Quark's multi-row MERGE shape, so QUARK falls back to N individual MERGE calls for Oracle to avoid duplicate identity values. :::


UpdateBatch

Updates multiple existing records by their primary key. Each entity receives a partial update — zero-value fields (0, "", false, nil) are not written, exactly like Update.

All updates are wrapped in a single transaction. If any row update fails, the entire batch is rolled back.

// Fetch some records, modify in memory, bulk-write 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 // all changes rolled back automatically
}

Zero-value semantics

Because UpdateBatch reuses the partial-update logic from Update, a struct like:

&User{ID: 5, Score: 200}

generates:

UPDATE users SET score = 200 WHERE id = 5

and leaves name, email, and other fields untouched.

Use UpdateMap for individual rows when you need to write explicit zero values.

Atomicity guarantee

A TestUpdateBatch_IsAtomic_RollsBackOnError test verifies that if any entity yields no updatable fields (or hits a DB error), the transaction rolls back and every previously processed row reverts to its original state.


DeleteBatch

Hard-deletes multiple rows by primary key using DELETE … WHERE pk IN (…).

ids := []any{10, 11, 12, 13, 14}
affected, err := quark.For[User](ctx, client).DeleteBatch(ids)
// affected == number of rows actually removed

Chunking

Oracle limits IN list expressions to 1000 elements. QUARK automatically chunks larger slices into sequential DELETE statements, each covering at most 1000 IDs, so you never need to chunk manually.

// 2500 IDs → three statements: 1000 + 1000 + 500
affected, err := quark.For[User](ctx, client).DeleteBatch(largeIDSlice)

Soft-delete note

DeleteBatch is a hard delete in the current API. If you need a bulk soft delete, use UpdateMap to set deleted_at under an explicit predicate.

Non-existent IDs

IDs that no longer exist do not produce errors — affected simply reflects the count of rows that were actually removed.

affected, err := quark.For[User](ctx, client).DeleteBatch([]any{99999})
// err == nil, affected == 0

Combining batch operations

A typical data-sync workflow uses all four operations together:

// 1. Bulk-insert new records
if err := quark.For[Product](ctx, client).CreateBatch(newProducts); err != nil {
return err
}

// 2. Upsert 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. Bulk-update prices in memory
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
}