Batch Operations
Bulk create, update, upsert, and delete records with optional fail-fast transactions.
Batch operations let you create, update, or delete multiple records in a single request. They are auto-enabled when the corresponding CRUD operation exists in your definition.
Available Endpoints
| Endpoint | Method | Auto-enabled When |
|---|---|---|
/{resource}/batch | POST | crud.create exists |
/{resource}/batch | PATCH | crud.update exists |
/{resource}/batch | DELETE | crud.delete exists |
/{resource}/batch | PUT | crud.put exists |
To disable a batch operation, set it to false in your definition:
crud: {
create: { access: { roles: ['hiring-manager'] } },
batchCreate: false, // Disable batch create
}Batch Create
POST /api/v1/{resource}/batchRequest
{
"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": "Database insert failed",
"layer": "validation",
"code": "INSERT_FAILED",
"details": { "reason": "UNIQUE constraint failed: applications.candidateId, applications.jobId" }
}
}
],
"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": "Not found",
"layer": "firewall",
"code": "NOT_FOUND",
"details": { "id": "app_xyz9" }
}
}
],
"meta": {
"total": 3,
"succeeded": 2,
"failed": 1,
"failFast": false,
"transactional": false
}
}Records that don't exist or aren't accessible through the firewall return a NOT_FOUND error. 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:
{
"error": "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."
}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).
Batch Upsert
PUT /api/v1/{resource}/batchInserts new records or updates existing ones. Every record must include an id field.
Note: Batch upsert is only available when generateId is set to false (user-provided IDs) in your configuration.
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)
{
"error": "Batch failed at index 1",
"layer": "validation",
"code": "BATCH_FAILFAST_STOPPED",
"details": {
"failedAt": 1,
"reason": "Database insert failed",
"errorDetails": { "reason": "UNIQUE constraint failed" }
}
}Transactional Semantics
The behavior of failFast: true depends on whether your database provider
supports transactions. The response's meta.transactional flag tells you
which guarantee you actually got — inspect it in clients that care about
rollback.
Per-Provider Matrix
| Endpoint | Provider | failFast | meta.transactional | Behavior |
|---|---|---|---|---|
PATCH /batch (update) | postgres / supabase / neon / libsql / better-sqlite3 / bun-sqlite | 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 | true | false | Fail-fast loop without rollback — D1 has no db.transaction. Records before the failure stay committed. |
PUT /batch (upsert) | tx-supporting | true | true | Bulk insert + per-record update share one transaction. Any failure rolls back both. |
PUT /batch (upsert) | cloudflare-d1 | 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. |
Reading meta.transactional in clients
const res = await fetch('/api/v1/jobs/batch', {
method: 'PATCH',
body: JSON.stringify({ records, options: { failFast: true } }),
});
const body = await res.json();
if (!body.meta.transactional && body.errors.length > 0) {
// Some records succeeded before the failure and remain committed.
// Compensate via the success array if needed.
}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:
{
"error": "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 CRUD operation. You can override per-batch settings:
export default defineTable(applications, {
crud: {
create: { access: { roles: ['hiring-manager', 'recruiter'] } },
update: { access: { roles: ['hiring-manager', 'recruiter'] } },
delete: { access: { roles: ['hiring-manager'] }, mode: 'soft' },
// Override batch-specific settings
batchCreate: {
access: { roles: ['hiring-manager'] }, // More restrictive than single create
maxBatchSize: 50, // Default: 100
allowFailFast: true, // Default: true
},
batchUpdate: {
maxBatchSize: 100,
allowFailFast: true,
},
batchDelete: {
access: { roles: ['hiring-manager'] },
maxBatchSize: 100,
allowFailFast: true,
},
// Disable a batch operation entirely
batchUpsert: 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