0046 - API standards for bulk processing resources
| ID: | ADR-0046 |
|---|---|
| Status: | PROPOSED |
| Published: | 2026-10-05 |
Notation
This ADR uses RFC 2119 keywords (MUST, MUST NOT,
SHOULD, SHOULD NOT, MAY) deliberately.
Context and problem statement
ADR-0036 requires service-to-service APIs to conform to a documented set of standards, and ADR-0039 builds those standards on IETF RFCs. This ADR sets how many resources are created, updated, replaced, deleted, or acted upon in one request.
A bulk operation is shorthand for calling the corresponding single-resource API once per resource, and is never a way around that API's rules. Every bulk operation has three properties, and every combination of them is legitimate:
- Targeting: how the caller names the resources (
data,ids, orfilter). - Atomicity: what happens when some resources fail (all-or-nothing or best-effort).
- Execution: whether the caller waits for the outcome (synchronous or asynchronous).
Considered options
- Bulk operations on the collection, one per kind of change, as below.
- JSON:API's Atomic Operations extension: a list of individual operations in one request.
- OData
$batchor HTTP multipart batching: many independent requests in one envelope. 207 Multi-Statusto report best-effort results.- No bulk APIs: callers loop over the single-resource APIs.
Decision outcome
Chosen option: bulk operations on the collection, because each one says exactly what it does,
can be authorized and validated as a whole, and can be implemented as one set-based statement where
the outcome allows. 207 is not used: it comes from WebDAV and implies an XML body, and callers
already inspect failedCount and errors to learn which resources failed.
Operations
All bulk operations use POST. The :verb syntax is credited to
Google AIP-136.
| Operation | Path | Targeting |
|---|---|---|
| Bulk create | /api/v1/{resource plural}:bulk-create | data |
| Bulk update | /api/v1/{resource plural}:bulk-update | ids or filter, plus update |
| Bulk replace | /api/v1/{resource plural}:bulk-replace | data |
| Bulk delete | /api/v1/{resource plural}:bulk-delete | ids or filter |
| Bulk action | /api/v1/{resource plural}:bulk-{action} | ids or filter, plus data |
Every bulk API MUST document in OpenAPI the targeting it accepts, the atomicity modes it supports
and which is the default, and whether it responds synchronously, asynchronously, or both.
Targeting
datais an array of resources, each shaped exactly as the single-resource API expects.idsis an array of resource IDs.filteris an advanced query expression (see ADR-0043).- A request
MUSTsupply exactly one form of targeting among those the operation accepts. - A
filterMUST NOTbe empty, andidsanddataMUST NOTname the same resource twice. An emptyidsordataarray is valid and affects nothing. - A request with more items than the documented maximum
MUSTbe rejected with422. - Targeting
MUST NOTwiden the caller's reach. Everyidsandfilteris restricted to the current organization and to the resources the caller may see, and an ID naming a resource the caller may not know ofMUSTbe reported exactly as a nonexistent one.
ids exists alongside filter because of what happens to an ID that matches nothing. With ids,
the caller named the resource, so a missing one is a failure. With filter, the caller described a
set, and a resource outside it is simply not in it.
Per-resource rules
- Each targeted resource
MUSTbe held to the same authorization, validation, and business rules as the single-resource API, including rules that depend on the resource's current state. - Each resource that changes
MUSTproduce the same side effects as the single-resource API, such as events, notifications, and audit records. - A service
MAYimplement a bulk operation however it likes, including as one set-based statement, provided the outcome, including which resources fail and why, is indistinguishable from applying the single-resource rules one resource at a time.
Atomicity
- All-or-nothing: no resource changes unless every one succeeds, and a failure reports every failing resource, not just the first.
- Best-effort: each resource succeeds or fails on its own. One failure
MUST NOTprevent or undo another resource's success. - Callers choose with
atomic=trueoratomic=false. A serviceMAYsupport either mode or both, and one that supports bothMUSTdefault to all-or-nothing. - Every bulk API
MUSTrecognizeatomic, even if it supports one mode, andMUSTreject a value it does not support with422. - Problems with the request itself, such as a malformed filter, too many items, or both
idsandfilter, reject the whole request before any resource is processed.
Prefer all-or-nothing when a partial result would leave related resources inconsistent, and best-effort when each resource stands alone and failures can be reported or retried individually, as in an import.
Execution
- Synchronous responses carry the outcome. Asynchronous responses are
202with a job (see ADR-0045). - A service that supports both
MUSTrespond synchronously unless the caller sendsPrefer: respond-async(RFC 7240), andSHOULDreturnPreference-Applied: respond-asyncwhen it honors it. ItMAYrespond asynchronously regardless, to requests over a documented size. - Callers
MUSThandle every response the API documents.
Prefer synchronous for small, bounded sets of cheap work, and asynchronous for unbounded sets or expensive work, such as calls to other services.
Responses
- On success, a bulk API returns
200with the outcome,202with a job, or204(all-or-nothing only) when every resource succeeded and nothing needs returning. - A
200response carriesmeta.affectedCount(always),meta.failedCount(best-effort),datawith the latest representation of each resource that succeeded (createsSHOULDreturn it, for the generated IDs, and othersMAY), anderrors(best-effort only) with at least one error per failed resource. Whendatais returned fordataoridstargeting, itMUSTfollow request order. - Each error
MUSTidentify its resource, bysource.pointerinto the request (such as/data/2/attributes/emailor/ids/2) or, forfiltertargeting, bymeta.resource. ItsstatusMUSTbe what the single-resource API would have returned, such as404,409, or422. - An all-or-nothing failure changes nothing and returns a standard error response with every
failure. Its status
MUSTbe409if every failure is a version conflict and422otherwise, never404, because the collection named in the path was found. - When an asynchronous job completes, it
MUSTreport the same counts and errors a synchronous response would have. - A bulk create
MAYaccept alidon each item to correlate created resources with request items. An API that acceptslidMUSTecho it.lidis credited to JSON:API.
POST /api/v1/users:bulk-create?atomic=false
Content-Type: application/json
{
"data": [
{ "attributes": { "email": "bob@example.com" }, "lid": "bob", "type": "user" },
{ "attributes": { "email": "alice@example.com" }, "lid": "alice", "type": "user" }
]
}
{
"data": [
{
"attributes": { "email": "bob@example.com" },
"id": "62bed180-1f78-45d4-8a56-c996936a2947",
"lid": "bob",
"type": "user"
}
],
"errors": [
{
"id": "9f3c1e2a-7d40-4c8b-9b17-2f5a1c6e83d1",
"status": "422",
"code": "email-in-use",
"title": "Email address is already in use",
"detail": "'alice@example.com' is already a user in this organization.",
"source": { "pointer": "/data/1/attributes/email" }
}
],
"meta": { "affectedCount": 1, "failedCount": 1 }
}
Operation-specific rules
- Bulk update applies the same change to every targeted resource.
updateMUSTbe present and has partial-update semantics, even on services that do not otherwise offerPATCH. ItMUSTcontain only fields the single-resource update accepts, and naming a read-only fieldMUSTbe rejected with422. A single-resource update ignores read-only fields because the caller sends back the whole resource it read, but a bulk update names only the fields to change, so a read-only field there is a request that cannot be honored. Update rulesMUSThold for each resource both before and after the change. Bulk updates do not checkversion, butMUSTchange it on every resource they change. A field that changes only through an action cannot be bulk-updated, so changing it for many resources is a bulk action. - Bulk replace is
PUTonce per resource. Each itemMUSTcarry itsid, services that check versionsMUSTcheck each item'sversion(a mismatch is a409for that item), and a bulk replaceMAYcreate resources if the single-resourcePUTcan. - Bulk delete follows the single-resource rules, including soft deletes and
permanent=true(see ADR-0044). - Bulk action paths use the single-resource action's name, as in
/api/v1/users:bulk-send-emailfor/api/v1/users/{id}/actions/send-email.dataholds the body the action accepts and applies to every targeted resource, andMAYbe omitted if the action takes none. Where each resource needs its own body,dataMUSTbe an array used withids, notfilter, of the same length, withdata[n]the body forids[n]and failures reported against/data/{n}.
Positive consequences
- Bulk operations behave exactly like their single-resource counterparts, so authorization, validation, and side effects cannot be bypassed by going bulk.
- Callers choose all-or-nothing or best-effort, and synchronous or asynchronous, explicitly.
- Per-resource failures are reported in the same error format as everything else.
Negative consequences
- Every bulk API has to implement and document three properties, and test the combinations it supports.
- Matching set-based implementations to one-at-a-time outcomes takes care, particularly for failures.