Batch Operations
Bulk create, update, upsert, and delete records with optional fail-fast transactions.
Batch operations let you create, update, upsert, or delete multiple records in a single request. They are auto-enabled when the corresponding write operation exists in your definition.
Available Endpoints
| Endpoint | Method | Auto-enabled When |
|---|---|---|
/{resource}/batch | POST | create exists |
/{resource}/batch | PATCH | update exists |
/{resource}/batch | DELETE | delete exists |
/{resource}/batch | PUT | upsert exists |
To disable a batch operation, set it to false in your definition:
create: {
access: { roles: ['hiring-manager'] },
batch: false, // Disable batch create
}Batch Create
POST /api/v1/{resource}/batchWith allowClientIds: true
each record MAY carry a strategy-shaped id; records without one get
server-minted ids as usual.
Request
{
"records": [
{ "candidateId": "cand_101", "jobId": "job_201", "stage": "applied", "notes": "Strong frontend background" },
{ "candidateId": "cand_101", "jobId": "job_202", "stage": "applied", "notes": "Also interested in backend role" },
{ "candidateId": "cand_102", "jobId": "job_201", "stage": "applied", "notes": "Referred by employee" }
],
"options": {
"failFast": false
}
}Response (201 or 207)
{
"success": [
{ "id": "app_abc1", "candidateId": "cand_101", "jobId": "job_201", "stage": "applied", "createdAt": "2025-01-15T10:00:00Z" },
{ "id": "app_abc3", "candidateId": "cand_102", "jobId": "job_201", "stage": "applied", "createdAt": "2025-01-15T10:00:00Z" }
],
"errors": [
{
"index": 1,
"record": { "candidateId": "cand_101", "jobId": "job_202", "stage": "applied", "notes": "Also interested in backend role" },
"error": {
"error": "Duplicate value",
"layer": "database",
"code": "DB_UNIQUE_VIOLATION",
"details": {
"column": "jobId",
"columns": ["jobId", "candidateId"],
"fields": { "jobId": "Already taken", "candidateId": "Already taken" }
},
"hint": "A record with this \"jobId\" + \"candidateId\" already exists"
}
}
],
"meta": {
"total": 3,
"succeeded": 2,
"failed": 1,
"failFast": false,
"transactional": true
}
}- 201 — All records created successfully
- 207 — Partial success (some errors, some successes)
POST /batch uses a single bulk INSERT, so meta.transactional is true on every provider — the database guarantees the insert is atomic regardless of the failFast flag.
Fields applied automatically to each record:
- ID generation (UUID, prefixed, etc.)
- Ownership fields (
organizationId,ownerId,createdBy) - Audit fields (
createdAt,modifiedAt) - Default values and computed fields
Batch Update
PATCH /api/v1/{resource}/batchEvery record must include an id field.
Request
{
"records": [
{ "id": "app_abc1", "notes": "Passed phone screen, schedule onsite" },
{ "id": "app_abc2", "notes": "Moved to technical interview round" },
{ "id": "app_xyz9", "notes": "Hiring manager approved offer" }
],
"options": {
"failFast": false
}
}Response (200 or 207)
{
"success": [
{ "id": "app_abc1", "notes": "Passed phone screen, schedule onsite", "modifiedAt": "2025-01-15T11:00:00Z" },
{ "id": "app_abc2", "notes": "Moved to technical interview round", "modifiedAt": "2025-01-15T11:00:00Z" }
],
"errors": [
{
"index": 2,
"record": { "id": "app_xyz9", "notes": "Hiring manager approved offer" },
"error": {
"error": "Record not found or not accessible",
"layer": "firewall",
"code": "FIREWALL_NOT_FOUND",
"details": { "id": "app_xyz9" },
"hint": "Check the record ID and your organization membership"
}
}
],
"meta": {
"total": 3,
"succeeded": 2,
"failed": 1,
"failFast": false,
"transactional": false
}
}Records that don't exist or aren't accessible through the firewall return
FIREWALL_NOT_FOUND — or the opaque NOT_FOUND when the resource sets
firewallErrorMode: 'hide'. Guard rules (immutable, protected, not-updatable
fields) are checked per record.
Missing IDs
If any records are missing the id field, the entire request is rejected with
a Problem Details document:
{
"type": "https://quickback.dev/problems/batch-missing-ids",
"title": "Batch missing ids",
"status": 400,
"detail": "Records missing required ID field",
"layer": "validation",
"code": "BATCH_MISSING_IDS",
"details": { "indices": [0, 2] },
"hint": "All records must include an ID field for batch update/upsert operations."
}Batch Delete
DELETE /api/v1/{resource}/batchRequest
{
"ids": ["app_abc1", "app_abc2", "app_abc3"],
"options": {
"failFast": false
}
}Note: Batch delete uses an ids array (not records).
Response (200 or 207)
Soft delete (default):
{
"success": [
{ "id": "app_abc1", "candidateId": "cand_101", "deletedAt": "2025-01-15T12:00:00Z" },
{ "id": "app_abc2", "candidateId": "cand_103", "deletedAt": "2025-01-15T12:00:00Z" }
],
"errors": [],
"meta": {
"total": 2,
"succeeded": 2,
"failed": 0,
"failFast": false,
"transactional": true
}
}DELETE /batch issues a single bulk UPDATE … WHERE id IN (...) (soft delete) or DELETE … WHERE id IN (...) (hard delete), so meta.transactional is true on every provider regardless of failFast.
Hard delete: Returns objects with only the id field (record data is deleted).
If the bulk statement itself fails, the error is classified like every other
write — a constraint hit reports its DB_* code (for example
DB_FOREIGN_KEY_VIOLATION, 400), anything else DB_DELETE_FAILED (500); see
Database errors. With failFast: true that
error is the response, at its own status. Otherwise it is repeated in errors
for every id that passed the existence and access checks, and the response is
207. The raw database message reaches details.reason only when
EXPOSE_ERROR_STACK is set.
Batch Upsert
PUT /api/v1/{resource}/batchInserts new records or updates existing ones. Every record must include an id field.
When create and update access policies are configured, batch upsert requires create access for the route. Existing records also receive the update access check, including any record-level rule. A caller who can create but cannot update may insert new IDs; existing IDs fail their update check.
Note: Batch upsert requires both generateId: false (user-provided IDs) in your database configuration and guards: false on the table. Explicitly enabling crud.batchUpsert without both settings is a compile error. Use crud.batchUpdate for guarded updates.
Request
{
"records": [
{ "id": "app_001", "candidateId": "cand_101", "jobId": "job_201", "stage": "screening", "notes": "Updated after phone screen" },
{ "id": "app_002", "candidateId": "cand_104", "jobId": "job_203", "stage": "applied", "notes": "New application from referral" }
],
"options": {
"failFast": false
}
}Response (201 or 207)
The compiler checks which IDs already exist and splits the batch into creates and updates. Create records get ownership and default fields; update records get only modifiedAt. Under failFast: true, both the bulk insert and the per-record updates run inside the same transaction — a failed update rolls back the inserts too on tx-supporting providers.
Fail-Fast Mode
By default, batch operations use partial success mode — each record is processed independently, and failures don't affect other records.
Set "failFast": true to stop on the first error:
{
"records": [...],
"options": {
"failFast": true
}
}Fail-Fast Failure Response (400)
{
"detail": "Batch stopped at first error (fail-fast mode)",
"layer": "validation",
"code": "BATCH_FAIL_FAST_STOPPED",
"details": {
"failedAt": 1,
"applied": true,
"reason": "Duplicate value",
"errorDetails": { "column": "email", "columns": ["email"], "fields": { "email": "Already taken" } }
}
}details.reason here is the failing record's error message, and
details.errorDetails its details. A database fallback
(DB_INSERT_FAILED / DB_UPDATE_FAILED) has no details unless
EXPOSE_ERROR_STACK is set — see Errors.
Transactional Semantics
The behavior of failFast: true depends on whether your database provider —
and, for Neon, its connection mode — supports interactive transactions.
The response's meta.transactional flag tells you which guarantee you
actually got — inspect it in clients that care about rollback.
Neon is transactional over websocket (connectionMode: 'websocket', the
default on Node/Bun runtimes) and over hyperdrive (postgres.js on a
Cloudflare Hyperdrive binding — the only Neon mode with interactive
transactions on Workers). Over HTTP — the default on Cloudflare Workers — the
drizzle-orm/neon-http driver has no db.transaction() (it throws at
runtime), so Neon-over-HTTP batches behave exactly like D1: fail-fast
per-record loop, meta.transactional: false.
PlanetScale Postgres is Hyperdrive-only
and therefore always transactional — it shares the postgres-js-hyperdrive
transport with Neon's hyperdrive mode.
Per-Provider Matrix
| Endpoint | Provider | failFast | meta.transactional | Behavior |
|---|---|---|---|---|
PATCH /batch (update) | tx-supporting (postgres, neon over websocket/hyperdrive, planetscale-postgres) | true | true | db.transaction() wraps the per-record loop. Any failure rolls back ALL writes in the batch. |
PATCH /batch (update) | (any) | false | false | Per-record loop, partial success. Each record commits independently. |
PATCH /batch (update) | cloudflare-d1, neon over http (Cloudflare default) | true | false | Fail-fast loop without rollback — the driver has no db.transaction. Records before the failure stay committed. |
PUT /batch (upsert) | tx-supporting (postgres, neon over websocket/hyperdrive, planetscale-postgres) | true | true | Bulk insert + per-record update share one transaction. Any failure rolls back both. |
PUT /batch (upsert) | cloudflare-d1, neon over http (Cloudflare default) | true | false | Bulk insert commits; per-record update loop is fail-fast without rollback. |
POST /batch (create) | (any) | (any) | true | Single bulk INSERT — atomic at the DB level. |
DELETE /batch | (any) | (any) | true | Single bulk UPDATE (soft) or DELETE (hard) — atomic at the DB level. |
Changeset (application/vnd.quickback.changeset+json) | tx-supporting (postgres, neon over websocket/hyperdrive, planetscale-postgres) | n/a | true | Root op + every child op share one transaction; any op failure rolls back the whole changeset. |
| Changeset | cloudflare-d1, neon over http (Cloudflare default) | n/a | false | Deterministic parent-first fail-fast — the first op failure stops the changeset; already-applied ops stay committed (a consistent, independently-authorized prefix). |
Reading meta.transactional in clients
meta is part of the success envelope (200 / 207). A fail-fast failure is a
400 problem document instead, with no meta — read details.applied there:
const res = await fetch('/api/v1/jobs/batch', {
method: 'PATCH',
body: JSON.stringify({ records, options: { failFast: true } }),
});
const body = await res.json();
if (res.ok) {
// body.meta.transactional: true when the batch ran in one transaction.
} else if (body.code === 'BATCH_FAIL_FAST_STOPPED') {
if (body.details.applied) {
// Records before body.details.failedAt were committed and stay committed.
// Reconcile before resending, or they apply twice.
} else {
// Nothing was written: fix records[body.details.failedAt] and resend.
}
}Embeddings and Realtime
When a resource has embeddings or realtime configured, queue messages
and broadcasts are emitted after the transaction commits. A
rolled-back batch produces no orphan embedding jobs.
Batch Size Limit
The default maximum batch size is 100 records. Requests exceeding the limit are rejected:
{
"type": "https://quickback.dev/problems/batch-size-exceeded",
"title": "Batch size exceeded",
"status": 400,
"detail": "Batch size limit exceeded",
"layer": "validation",
"code": "BATCH_SIZE_EXCEEDED",
"details": { "max": 100, "actual": 250 },
"hint": "Maximum 100 records allowed per batch. Split into multiple requests."
}Configuration
Batch operations inherit access control from their corresponding write operation. You can override per-batch settings:
import { feature, q } from "@quickback/compiler";
export default feature("applications", {
columns: {
id: q.id(),
candidateName: q.text({ maxLength: 200 }).required(),
status: q.enum(['applied', 'screening', 'offer']).default('applied').required(),
organizationId: q.scope("organization"),
...q.audit(),
...q.softDelete(),
},
create: {
access: { roles: ['hiring-manager', 'recruiter'] },
batch: {
access: { roles: ['hiring-manager'] }, // More restrictive than single create
maxBatchSize: 50, // Default: 100
allowFailFast: true, // Default: true
},
},
update: {
access: { roles: ['hiring-manager', 'recruiter'] },
batch: {
maxBatchSize: 100,
allowFailFast: true,
},
},
delete: {
access: { roles: ['hiring-manager'] },
mode: 'soft',
batch: {
access: { roles: ['hiring-manager'] },
maxBatchSize: 100,
allowFailFast: true,
},
},
// Disable only batch upsert
upsert: {
access: { roles: ['hiring-manager'] },
batch: false,
},
});Security
All security pillars apply to batch operations:
- Authentication — Required for all batch endpoints (401 if missing)
- Firewall — Applied per-record for update/delete/upsert (not applicable to create)
- Access — Role-based check using the batch operation's access config
- Guards — Per-record validation (createable/updatable/immutable fields)
- Masking — Applied to all records in the success array before response
See Also
- CRUD Endpoints — Single-record operations
- Error Responses — Error format reference
- Guards — Field-level write protection