Skip to main content
Version: v1.17.0

CRUD Operations API Reference

Every write method on *quark.Query[T]: create, update, delete, upsert, and the batch forms. Read execution methods live in Querying, and the builder methods that shape a query in Query Builder.

For the narrative version — what Update skips and why, when a delete is soft — see Getting Started and Batch Operations.

SymbolSignaturePurpose
CreateCreate(entity *T) errorValidate and insert one entity.
CreateBatchCreateBatch(entities []*T) errorValidate and insert many entities.
UpdateUpdate(entity *T) (int64, error)Update one entity by PK (skips zero values).
TrackTrack() *TrackedQuery[T]Switch reads to dirty-tracking wrappers for diff-based Save.
UpdateFieldsUpdateFields(entity *T, fields ...string) (int64, error)Update only named fields, including zero values.
UpdateMapUpdateMap(data map[string]any) (int64, error)Update explicit columns for Where-matched rows.
DeleteDelete(entity *T) (int64, error)Soft-delete by PK if deleted_at exists, else hard-delete.
HardDeleteHardDelete(entity *T) (int64, error)Permanently delete by PK even when soft delete is enabled.
DeleteByDeleteBy() (int64, error)Hard-delete rows matching the current Where clauses.
DeleteBatchDeleteBatch(ids []any) (int64, error)Hard-delete rows by primary-key values.
DeleteBatchOfDeleteBatchOf[T, V](q *Query[T], ids []V) (int64, error)Package-level typed-slice form of DeleteBatch.
WithoutAssociationsWithoutAssociations() *Query[T]Disable the recursive association save for this Create/Update.
CheckVersionsCheckVersions() *Query[T]Make UpdateBatch on a versioned model fail with ErrStaleEntity, all or nothing, when a row's version moved.
UpsertUpsert(entity *T, conflictCols, updateCols []string) errorInsert or update one entity on conflict.
UpsertBatchUpsertBatch(entities []*T, conflictCols, updateCols []string) errorBulk insert-or-update.
UpdateBatchUpdateBatch(entities []*T) errorUpdate many entities by PK in one transaction.

Create​

Create(entity *T) error​

Validates and inserts one entity.

user := &User{
Email: "alice@example.com",
Name: "Alice",
}

err := quark.For[User](ctx, client).Create(user)

Behavior:

StepDetail
ValidationCalls client.Validate(ctx, entity).
HooksRuns BeforeCreate and AfterCreate when implemented.
AssociationsRecursively saves belongs_to, main entity, has_one, has_many, then many-to-many links.
Primary keyWrites back integer PKs when the dialect supports RETURNING or last-insert ID.
CacheInvalidates the model table tag and the new row's <table>:<pk> tag once the row is inserted; inside a transaction, again when it commits. See Caching.
TenantUnder RowLevelSecurityClient the tenant column is set to the resolved tenant, whatever the entity carried. A different value is replaced, not written, and logged (quark.tenant.foreign_value_replaced). The same holds for Update, UpdateFields, UpdateBatch, UpdateMap, CreateBatch and the upserts.

The entity must be a non-nil pointer to a struct.

CreateBatch​

CreateBatch(entities []*T) error​

Validates and inserts multiple entities.

users := []*User{
{Email: "a@example.com", Name: "A"},
{Email: "b@example.com", Name: "B"},
}

err := quark.For[User](ctx, client).CreateBatch(users)

Behavior:

DialectShape
PostgreSQL, SQLite, MySQL, MariaDB, SQL ServerMulti-row INSERT ... VALUES ..., chunked so each statement stays under the dialect's bind-parameter ceiling (SQL Server ~2100).
SQL Server, generated keyOne insert-only MERGE ... OUTPUT per chunk, which returns each key with its row's position, on an IDENTITY key without an INSTEAD OF trigger; one row at a time otherwise.
MySQL, generated keyOne multi-row INSERT per chunk, keys computed from LAST_INSERT_ID() and auto_increment_increment, when the server runs innodb_autoinc_lock_mode 0 or 1 on an InnoDB table without INSERT triggers; one row at a time otherwise, including MySQL 8's default (2).
OracleIndividual single-row inserts to avoid identity-column conflicts.

Where a chunk with generated keys goes in one statement on SQL Server or MySQL and a row of it fails, the statement is undone and the chunk runs again one row at a time, so the result is the one the row-at-a-time form gives: the rows before the failing one inserted with their keys set, and the error. See Batch Operations.

Large slices are chunked automatically, so a call with tens of thousands of rows will not overrun a dialect's parameter limit. Chunks run on the bound executor and are not wrapped in an implicit transaction — wrap CreateBatch in client.Tx if you need all-or-nothing across chunks.

If the first entity has a zero single-column integer primary key, Quark omits that PK column and lets the database assign it. Dialects with RETURNING can write generated primary keys back to the entity pointers.

CreateBatch returns nil for an empty slice.

Update​

Update(entity *T) (int64, error)​

Updates one entity by primary key using partial-update semantics.

user := &User{
ID: 42,
Name: "Alice Walker",
}

rows, err := quark.For[User](ctx, client).Update(user)

Zero values are skipped:

Zero valueSkipped by Update
""yes
0yes
falseyes
nil pointer/slice/mapyes
Zero-value trap

Because Update filters out zero values, it cannot write false, 0, "", or nil to a column. To put active = false into the database use Track + Save (recommended; the snapshot-driven escape hatch), UpdateFields, or UpdateMap. When Update detects skipped zero-value fields it logs a WARN line so you notice the silent skip.

The zero-value filter is preserved on Update itself for behavioural compatibility; reach for Track + Save when you want to mutate a struct freely and let Quark decide what to write.

Recursive association save

Update recursively saves every loaded association, exactly like Create. An entity read with Find + Preload carries its children in memory, so updating one scalar field also re-writes every loaded child from that snapshot — overwriting concurrent changes to those rows, and without the extra writes showing in the returned rows-affected count. Update logs a WARN naming the associations it is about to write; chain WithoutAssociations() to write only the entity's own row:

got, _ := quark.For[User](ctx, client).Preload("Posts").Find(1)
got.Name = "renamed"
rows, err := quark.For[User](ctx, client).WithoutAssociations().Update(&got)

See Relations — Update re-saves loaded associations.

Guarding a write by key with Where​

Update writes the entity by its primary key (and its version column, when the model has one), ANDed with the query's Where conditions. The conditions can only narrow which row is written: the row is written when a List with the same conditions would return it, and left alone otherwise. Use them to check, in the same statement, that the row an id names is one the caller may write:

// id comes from the request; the row is written only if it is the caller's.
doc := Document{ID: id, Title: input.Title}
rows, err := quark.For[Document](ctx, client).
Where("owner_id", "=", userID).
Update(&doc)
if err != nil {
return err
}
if rows == 0 {
return ErrNotFound // no document with that id belongs to userID
}
// emitted: UPDATE "documents" SET "title" = $1 WHERE "id" = $2 AND ("owner_id" = $3)

The same holds for every write that addresses a row by key: Update, UpdateFields, UpdateBatch, Delete, HardDelete, DeleteBatch and Restore. Under the RowLevelSecurityClient tenant strategy the tenant predicate is one of the conditions, so these writes never touch another tenant's row.

When no row with the key satisfies the conditions:

OutcomeWhy
Nothing is written, and (0, nil) is returned.The same answer UpdateMap and DeleteBy give when their conditions match nothing; check the count.
AfterUpdate / AfterDelete do not run, no audit row is written, no event is emitted.Nothing happened. BeforeUpdate / BeforeDelete have already run: they run before the statement.
Loaded associations are not written either.Update checks the row before writing a belongs_to association, which it writes ahead of the entity's row.
A versioned model returns (0, nil), not ErrStaleEntity.ErrStaleEntity means the row satisfies the conditions and its version moved. UpdateBatch under CheckVersions skips such a row the same way.

The answer does not say whether the row exists outside the conditions: a row of another tenant and a row that does not exist look the same. An entity whose key is zero is inserted, as before; the conditions do not apply to an insert.

Hooks: BeforeUpdate and AfterUpdate.

Track + Save (dirty tracking)​

Query[T].Track() *TrackedQuery[T]​

Opt-in dirty tracking. Track swaps the result type of Find / First / List from T / []T to *Tracked[T] / []*Tracked[T]. Each tracked value carries a snapshot of the column values at load time, so a later Save can emit an UPDATE that touches only the columns whose values actually changed — and writes them even when the new value is zero.

tracked, err := quark.For[User](ctx, client).Track().Find(42)
if err != nil { return err }

tracked.Entity.Active = false
tracked.Entity.Score = 0

rows, err := tracked.Save(ctx)
// emits: UPDATE "users" SET "active" = $1, "score" = $2 WHERE "id" = $3
// args = [false, 0, 42]

Tracked.Changed() returns the list of columns whose values differ from the snapshot; Tracked.Save(ctx) runs the UPDATE and refreshes the snapshot on success, so a second Save with no further mutation is a no-op (returns 0, nil without touching the database).

Rules:

RuleWhy
The primary-key column is never included in SET.Identity is WHERE-only.
The configured tenant column is never written.RLS isolation boundary; a Save cannot move a row across tenants.
The tenant predicate from the loading query is added to the WHERE.Same isolation guarantee as the rest of the multi-tenant API.
Hooks BeforeUpdate / AfterUpdate run, just like on Update.Behaviour parity with the partial-update path.
Save sidesteps the zero-value trap.Update's zero-value filter doesn't apply — values are written based on the diff, not on whether they're zero.

The snapshot lives on the wrapper, not in the Client, so there is no shared map to grow or evict. Each Tracked is independent — pass it around, mutate it, save it whenever you want.

UpdateFields​

UpdateFields(entity *T, fields ...string) (int64, error)​

Updates only the named fields on the entity, bypassing the zero-value filter. This is the recommended escape hatch for writing false, 0, "", or nil.

user := User{ID: 42, Active: false}
rows, err := quark.For[User](ctx, client).UpdateFields(&user, "active")
// emitted: UPDATE "users" SET "active" = $1 WHERE "id" = $2 args=[false, 42]

Rules:

RuleReason
Field name matches the db tag (or struct field name when no db tag is set).Same identifier resolution as Update/Find.
Listing the primary key column returns an error.Overwriting the PK would corrupt the row's identity.
Listing an unknown field returns an error.Fail fast on typos.
Empty fields returns an error.An update with no SET clause is meaningless.
Existing Where() conditions are ANDed with the PK predicate.The row is written only when a List with the same conditions would return it; see Guarding a write by key.

Hooks: BeforeUpdate and AfterUpdate run, just like Update.

UpdateMap​

UpdateMap(data map[string]any) (int64, error)​

Updates explicit columns for rows matching the current Where conditions.

rows, err := quark.For[User](ctx, client).
Where("id", "=", 42).
UpdateMap(map[string]any{
"active": false,
"score": 0,
})

Rules:

RuleReason
data must not be empty.Prevents invalid UPDATE ... SET.
At least one Where is required.Prevents accidental full-table updates.
Map keys are sorted.Produces deterministic SQL.
Keys are validated as identifiers.Protects dynamic update maps.
Under RowLevelSecurityClient the tenant column, if the map names it, is written with the resolved tenant.A map cannot move a row to another tenant; a different value is logged (quark.tenant.foreign_value_replaced).

UpdateMap is the preferred API for explicit zero values, admin bulk changes, and computed updates that do not require loading a full entity.

The rows UpdateMap, UpdateFields and DeleteBy write are the rows List returns for the same conditions. WhereNot, Or groups, IN, BETWEEN, IS NULL, the escaped LIKE forms and WhereExpr mean the same thing in an UPDATE or a DELETE as in a SELECT. One difference stays: these writes do not add the soft-delete scope (deleted_at IS NULL) that reads add.

The scopes Quark adds — the soft-delete filter on reads and on Delete, the tenant predicate of RowLevelSecurityClient on everything — are ANDed with your conditions as a whole, Or groups included:

quark.For[User](ctx, client).
Where("name", "=", "a").
Or(func(q *quark.Query[User]) *quark.Query[User] { return q.Where("name", "=", "b") }).
List()
// WHERE "deleted_at" IS NULL AND ("name" = $1 OR ("name" = $2))

Delete​

Delete(entity *T) (int64, error)​

Deletes one entity by primary key.

If the model has a deleted_at column, Delete soft-deletes:

type User struct {
ID int64 `db:"id" pk:"true"`
Email string `db:"email"`
DeletedAt *time.Time `db:"deleted_at"`
}

rows, err := quark.For[User](ctx, client).Delete(&User{ID: 42})

Generated shape:

UPDATE users
SET deleted_at = CURRENT_TIMESTAMP
WHERE id = ? AND deleted_at IS NULL

If the model does not have deleted_at, Delete performs a hard delete by primary key.

The query's Where conditions are ANDed with the key, as for Update: when no row with the key satisfies them, nothing is deleted, (0, nil) is returned and AfterDelete, the audit row and the event are skipped. See Guarding a write by key.

rows, err := quark.For[Document](ctx, client).
Where("owner_id", "=", userID).
Delete(&Document{ID: id})
// emitted: UPDATE "documents" SET "deleted_at" = CURRENT_TIMESTAMP
// WHERE "id" = $1 AND "deleted_at" IS NULL AND ("owner_id" = $2)

Hooks: BeforeDelete and AfterDelete.

HardDelete(entity *T) (int64, error)​

Permanently deletes one entity by primary key even when soft delete is enabled.

rows, err := quark.For[User](ctx, client).HardDelete(&User{ID: 42})

The query's Where conditions are ANDed with the key, as for Delete.

DeleteBy() (int64, error)​

Hard-deletes the rows List returns for the current Where conditions — WhereNot included: WhereNot("status", "=", "active").DeleteBy() removes the rows whose status is not active.

rows, err := quark.For[User](ctx, client).
Where("active", "=", false).
DeleteBy()

DeleteBy requires at least one Where clause. It always hard-deletes; it does not honour soft delete. For a bulk soft delete, use UpdateMap:

rows, err := quark.For[User](ctx, client).
Where("active", "=", false).
UpdateMap(map[string]any{"deleted_at": time.Now()})

DeleteBatch(ids []any) (int64, error)​

Hard-deletes rows by primary key values.

ids := []any{1, 2, 3}
rows, err := quark.For[User](ctx, client).DeleteBatch(ids)

The method chunks IDs in groups of 1000 so the generated IN list remains inside supported dialect limits. Empty input returns (0, nil).

The query's Where conditions are ANDed with the ids: an id whose row does not satisfy them is not deleted, and the count says how many were.

Upsert​

Upsert(entity *T, conflictCols []string, updateCols []string) error​

Inserts the entity or updates it when a conflict occurs.

product := &Product{
SKU: "ABC-123",
Name: "Widget",
Price: 1999,
}

err := quark.For[Product](ctx, client).Upsert(
product,
[]string{"sku"},
[]string{"name", "price"},
)

conflictCols should match a unique constraint or primary key (empty conflictCols returns ErrInvalidQuery). Pass updateCols explicitly for portable behavior.

An empty updateCols does not mean the same on every engine. On four engines the conflicting row is left as it was — insert-or-ignore; on the two whose upsert is a MERGE, the non-conflict columns are overwritten:

DialectEmpty updateCols, the key conflictsErrorKey written back
PostgreSQL, SQLitethe row is left as it was (ON CONFLICT … DO NOTHING)nilno
MySQL, MariaDBthe row is left as it was (ON DUPLICATE KEY UPDATE assigns the first conflict column to itself)nilthe conflicting row's
SQL Server, Oracleevery column the insert writes is overwritten, except the conflict columns, the primary key and created_at (MERGE … WHEN MATCHED THEN UPDATE SET …)nilthe updated row's

On SQL Server and Oracle the update branch leaves the row's key and its created_at as they were, as Update does, and writes updated_at with the rest. An entity that carries a non-zero integer key is upserted like one that does not: the key is an identity column there, which the update branch does not set and the insert branch does not write. A new row gets the key the engine assigns, and that key is written into the entity — see the key written into the entity. The other four engines insert the entity's key. A new key is inserted on every engine.

UpsertBatch does the same per row. Bringing the engines together changes what callers see on one side or the other, so it waits for the next major release; until then, pass updateCols.

Which duplicate takes the update branch. On PostgreSQL and SQLite conflictCols is the conflict target: a duplicate of another unique key — the primary key, say — fails with ErrConstraintViolation. On SQL Server and Oracle the MERGE matches on conflictCols only. MySQL and MariaDB have no conflict target: ON DUPLICATE KEY UPDATE fires on a duplicate of any unique key, so the row that holds the duplicate takes the update branch whichever key it is:

DialectThe entity carries the key of an existing row and a conflictCols value no row has
PostgreSQL, SQLiteErrConstraintViolation; nothing is written
MySQL, MariaDBnil; with updateCols, that row's updateCols are written; without, the row is left as it was
SQL Server, Oraclenil; a new row is inserted, with a key the engine assigns, which the entity gets

conflictCols does not choose the row on MySQL and MariaDB. When a table has more than one unique key, upsert entities whose other keys do not collide with another row's, or use Create and Update.

DialectShape
PostgreSQLINSERT ... ON CONFLICT (...) DO UPDATE SET ...
SQLiteINSERT ... ON CONFLICT (...) DO UPDATE SET ...
MySQL / MariaDBINSERT ... ON DUPLICATE KEY UPDATE ...
SQL ServerMERGE INTO ... USING ...
OracleMERGE INTO ... USING ...

Validation runs before the SQL is built.

Cache. Upsert invalidates the table tag, and the <table>:<pk> tag of the row it inserted or updated when the statement reads that key back (the table below), on every engine. See Caching.

Oracle 23ai or later. On Oracle, Upsert and UpsertBatch need Oracle Database 23ai. The MERGE Quark writes reads its source row from a SELECT without FROM, and for a model whose key is one integer column it reads the key back with MERGE … RETURNING. Oracle accepts both from 23ai on; 19c and 21c do not. Quark's Oracle lane runs Oracle Database 23ai Free, and no earlier release is tested.

The key written into the entity​

When Upsert returns nil and the model's key is one integer column, the entity's key is the key of the row the statement inserted or updated, read from that same statement, whether the entity carried a key or not. Where the statement does not say which row that is, the entity keeps the key it carried. It is never set to the key of another row.

DialectRow insertedRow updatedConflict, nothing writtenRead with
PostgreSQL, SQLiteits keyits keykept: DO NOTHING returns no rowRETURNING
MySQLits keyits keythe conflicting row'sthe statement's own insert id; the clause ends with <pk> = LAST_INSERT_ID(<pk>), which changes no value
MariaDBits keyits keythe conflicting row'sRETURNING
SQL Serverits key, the one the identity assignedits keykept: a MERGE with no column to update has no update branchMERGE … OUTPUT INSERTED.<pk> INTO a table variable
Oracleits key, the one the identity assignedits keykept, as on SQL ServerMERGE … RETURNING <pk> BULK COLLECT INTO

"Conflict, nothing written" is an empty updateCols on PostgreSQL and SQLite, an update branch that changes no value on MySQL and MariaDB, and a model with no column outside the key and the conflict columns on SQL Server and Oracle. When the conflict columns match more than one row — they are not a unique key — SQL Server and Oracle update all of them and the entity keeps its key. MySQL reads the key from the statement's result, not from SELECT LAST_INSERT_ID(): that value belongs to a pooled connection, and on the update branch it is the last key that connection generated, not the conflicting row's. The same holds for a MariaDB server driven through quark.MySQL(). A key of another type or of more than one column is not read back on MySQL, SQL Server and Oracle; the entity keeps the one it carried.

Upserts under RowLevelSecurityClient​

The update branch only touches a row of the resolved tenant, and the row is inserted under the resolved tenant whatever the entity's tenant field holds:

DialectUpdate branch under the tenant
PostgreSQL, SQLiteON CONFLICT (...) DO UPDATE SET ... WHERE <table>.tenant_id = ?
MySQL, MariaDBON DUPLICATE KEY UPDATE c = IF(tenant_id = ?, VALUES(c), c), ..., id = IF(tenant_id = ?, LAST_INSERT_ID(id), id)
SQL ServerMERGE ... WHEN MATCHED AND target.tenant_id = @p THEN UPDATE ...
OracleMERGE ... WHEN MATCHED THEN UPDATE SET ... WHERE target.tenant_id = :n

When the conflict key is held by a row of another tenant, that row is not updated and no row is inserted — the key is taken. Upsert returns an error wrapping ErrConstraintViolation, the same error a Create of that key returns:

err := quark.For[Product](ctx, router).Upsert(product, []string{"sku"}, []string{"name"})
if errors.Is(err, quark.ErrConstraintViolation) {
// the SKU exists, and not in this tenant — or another unique key collided
}

Without an update branch — empty updateCols on PostgreSQL, SQLite, MySQL and MariaDB — a conflict with another tenant's row writes nothing and is not reported, as a conflict with the tenant's own row is not: Upsert returns nil. On SQL Server and Oracle an empty updateCols is an update branch, and a conflict with another tenant's row returns ErrConstraintViolation. A unique key that includes the tenant column ((tenant_id, sku)) avoids the collision altogether. On MySQL and MariaDB the last assignment reports the key of the tenant's row the statement met, and no key for another tenant's row: that is how the two are told apart when no value changed, and the key written into the entity is the one the table gives. A model whose key is not one integer column reads the row back by its conflict key within the tenant instead. On every engine a conflict with another tenant's row leaves the entity's key as it was. A dialect Quark has no guarded form for refuses the upsert under this strategy with ErrUnsupportedFeature, and so does a model without the tenant column.

UpsertBatch​

UpsertBatch(entities []*T, conflictCols []string, updateCols []string) error​

Bulk insert-or-update.

products := []*Product{
{SKU: "A", Name: "Alpha", Price: 1000},
{SKU: "B", Name: "Beta", Price: 2000},
}

err := quark.For[Product](ctx, client).UpsertBatch(
products,
[]string{"sku"},
[]string{"name", "price"},
)

Behavior:

DialectStrategy
PostgreSQL, SQLite, MySQL, MariaDBMulti-row insert with dialect upsert fragment.
SQL ServerSingle bulk MERGE ... USING (VALUES ...).
OracleOne MERGE per entity; Oracle 23ai or later, as for Upsert.

Empty input returns nil. All entities are validated before any SQL is built. An empty updateCols leaves a conflicting row as it was on PostgreSQL, SQLite, MySQL and MariaDB, and overwrites its non-conflict columns on SQL Server and Oracle — see Upsert.

The key written into each entity, for a model whose key is one integer column:

DialectKey written back
PostgreSQL, SQLite, MySQL, MariaDBnone: the multi-row INSERT reports no key per row, and each entity keeps the one it carried. Under RowLevelSecurityClient MySQL and MariaDB upsert one row at a time and write each key as Upsert does.
SQL Servereach entity gets its row's key, as Upsert does: the MERGE reads the keys back next to each row's position in the statement (OUTPUT src.quark_ord, INSERTED.<pk>).
Oracleeach entity gets its row's key, as Upsert does, from its own MERGE … RETURNING.

Under RowLevelSecurityClient each row's update branch is confined to the tenant as for Upsert, and the batch is all or nothing: it runs in a transaction of its own — or, on a query bound with ForTx, in a savepoint of the caller's — and a row whose conflict key belongs to another tenant fails the whole batch with ErrConstraintViolation. Inside the caller's transaction the savepoint undoes the rows the batch wrote, so they are not in the commit of a caller that goes on. MySQL and MariaDB upsert one row at a time under this strategy: a multi-row statement reports one count for the batch, which cannot tell a row of another tenant from a row of this one that already held its values.

UpdateBatch​

UpdateBatch(entities []*T) error​

Updates multiple entities by primary key inside one transaction.

users[0].Score += 10
users[1].Score += 10

ptrs := []*User{&users[0], &users[1]}
err := quark.For[User](ctx, client).UpdateBatch(ptrs)

Each row uses the same partial-update semantics as Update, the query's Where conditions included: an entity whose row does not satisfy them is not written, the way an entity whose key does not exist is not written. UpdateBatch returns no count, so when you need to know which rows were written, call Update per entity. If any row fails, the transaction rolls back.

On a query bound with ForTx the batch runs in the caller's transaction, inside a savepoint: if a row fails, the batch's rows are undone and the caller's transaction stays usable, and the caller's commit or rollback decides the rest. Before QK-57 it opened a transaction of its own on another connection there too — which waited for the caller's locks (on SQLite, until the query timeout) and committed apart from the caller.

UpdateBatch returns nil for empty input.

Versioned models and CheckVersions​

On a model with a quark:"version" field every UPDATE of the batch carries the version predicate, as Update does. What the batch does when a row's version moved depends on CheckVersions():

err := quark.For[Account](ctx, client).CheckVersions().UpdateBatch(accounts)
if errors.Is(err, quark.ErrStaleEntity) {
// nothing was written: reload, replay, retry
}
CaseUpdateBatchCheckVersions().UpdateBatch
A row whose version movednot written; the rest of the batch isthe whole batch is rolled back
A key that does not existnot writtenstale, as for Update, when the query has no conditions
A row the query's conditions excludenot writtennot written, and not an error
Errornilone ErrStaleEntity per stale row, joined, each naming its key: stale entity (optimistic-locking conflict): table accounts pk=7
In-memory version of a written rowunchanged, so the next Update of the same struct returns ErrStaleEntitybumped once the batch is through; a batch that fails bumps none

Every row is tried before the batch is rolled back, so the error names every stale row, not just the first. errors.Is(err, quark.ErrStaleEntity) holds for the joined error. On a ForTx query "the batch is through" means its savepoint was released without error; the caller's own rollback afterwards does not take the in-memory versions back, as it does not for Update. On a model without a version column CheckVersions changes nothing.

Quark 2.0 makes the CheckVersions behaviour the default for UpdateBatch. Until then the default stays as it was, because a batch that returned nil would start returning an error.

Composite Primary Keys​

For models with multiple pk:"true" fields, Update, Delete, and HardDelete include all primary key columns in the predicate.

type RolePermission struct {
RoleID int64 `db:"role_id" pk:"true"`
PermissionID int64 `db:"permission_id" pk:"true"`
Enabled bool `db:"enabled"`
}

rp := &RolePermission{RoleID: 1, PermissionID: 9, Enabled: true}
rows, err := quark.For[RolePermission](ctx, client).Update(rp)

Use Where(...).First() instead of Find(id) for composite-key reads.