What are REST API bulk operations and why do teams build them?
A bulk endpoint, sometimes called a batch endpoint, accepts one HTTP request that creates, updates or deletes many resources at once. Instead of a client issuing 500 separate POST /contacts calls, it sends a single request whose body carries 500 contact records. The server processes them and returns one response that describes what happened to each.
Teams build these endpoints for three practical reasons:
- Lower per-request overhead. Every HTTP request pays for authentication, rate-limit checks, routing, logging and connection handling. Grouping items lets the server pay those costs once rather than hundreds of times.
- Fewer round trips. Imports and synchronization jobs are often dominated by network latency, not processing time. A client on a high-latency link spends most of its wall-clock time waiting when it sends items one at a time.
- Transactional grouping. Some operations only make sense together, such as an order and its line items. A single request gives the server a natural boundary for applying them as a unit.
The demand usually comes from a few places. Platform teams expose data import for customers migrating from another system. Integration partners push records on a schedule. Internal sync jobs mirror data between services or reconcile state after an outage.
The URL shape is the easy part. Whether you choose POST /contacts/bulk or a generic /batch resource matters far less than the failure contract. Clients need to know what happens when item 312 fails, and the rest of this guide covers how to define that.
Should a bulk endpoint be all-or-nothing or allow partial success?
There are two coherent answers, and a bulk endpoint has to commit to one of them.
Atomic (all-or-nothing) runs the whole batch in a single transaction. Either every item is applied or none is, and one status code describes the outcome. This is easy for clients, because a failure means nothing changed and the request can be fixed and resent. The cost appears as batches grow. The transaction holds row or index locks for longer, contends with other writers, and is more likely to time out or deadlock. One bad record also blocks thousands of valid ones.
Partial success commits each item independently and reports a result per item. Throughput stays predictable and good records are not held back by bad ones. However, reconciliation moves to the client. It has to read every item result, work out which ones failed, and decide whether to retry them. Many clients only check the top-level status and never look further, so failures they did not read are silently lost.
| Criterion | All-or-nothing | Partial success |
|---|---|---|
| Items depend on each other (an order and its line items) | Preferred | Risky |
| Independent records (contact imports, event ingestion) | Wasteful | Preferred |
| Large or variable batch sizes | Lock contention, timeouts | Scales better |
| Client sophistication | Works with naive clients | Requires per-item handling |
| Failure recovery | Resend the whole batch | Retry only the failed items |
Use atomic semantics when a half-applied batch would leave data in an invalid business state. Use partial success when items are genuinely independent.
Whichever model you pick, state it explicitly in the endpoint's contract and documentation. Clients should not have to discover it by sending a malformed item and checking what was persisted.
How should a batch endpoint report partial success in the response?
The response body should mirror the request. Return a results array with exactly one entry per submitted item, in the same order. Index alignment alone is fragile, though. A client that filters, logs or retries a subset loses the mapping. Let clients attach their own identifier to each item (a client_id or reference field) and echo it back. The client can then reconcile results without counting positions.
Each result entry should be self-contained:
status: a per-item outcome such assucceeded,failedorskipped, or an HTTP-style code like201or422.error.code: a stable, machine-readable string (duplicate_email,invalid_sku) that clients can branch on.error.message: human-readable detail for logs and support tickets.id: the server-assigned resource ID for anything created. Without it, clients must issue follow-up queries to find what they just made.
Add a top-level summary with succeeded, failed and skipped counts. Clients can then decide whether to retry, alert or proceed without scanning the whole array.
Keep skipped distinct from failed. A skipped item was never attempted, usually because an item it depended on failed first. Give it an error code such as dependency_failed and reference the identifier of the failing item. This tells the client the item itself may be valid. It only needs resubmitting once the root cause is fixed.
That raises ordering. If items are processed sequentially and later items may reference earlier ones (create an order, then its line items via a temporary reference), document both guarantees explicitly. If processing is parallel, say so, and reject intra-batch references rather than resolving them unpredictably.
{
"processing": "sequential",
"summary": { "succeeded": 2, "failed": 1, "skipped": 1 },
"results": [
{
"client_id": "ord-1",
"status": "succeeded",
"id": "ord_8f3k2m"
},
{
"client_id": "line-1",
"status": "succeeded",
"id": "li_4n7p1q"
},
{
"client_id": "ord-2",
"status": "failed",
"error": {
"code": "invalid_sku",
"message": "SKU 'TSHIRT-XXL-RED' does not exist."
}
},
{
"client_id": "line-2",
"status": "skipped",
"error": {
"code": "dependency_failed",
"message": "Not attempted: depends on item 'ord-2', which failed.",
"depends_on": "ord-2"
}
}
]
}
A batch response with one result per item in request order, echoed client IDs, server-assigned IDs for created resources, and a skipped item that points to the failed item it depended on.
Which HTTP status code should a bulk request return: 200, 207 Multi-Status, 4xx or 5xx?
Status codes should answer one question: did the server accept and process the request as a whole? Whether each item succeeded is a separate question, and the results body answers it.
Request-level failures stop processing before any item runs, so a normal 4xx fits. Use 400 for a malformed body, 401 or 403 for failed auth, and 413 or 422 for a batch over the documented item limit. Clients already handle these codes, and none of them is ambiguous because nothing was applied.
| Outcome | Status | Where the detail lives |
|---|---|---|
| Body unparseable, auth failed, batch too large | 4xx | Standard error body |
| Batch processed, every item succeeded | 200 | Per-item results |
| Batch processed, some items failed | 200 or 207 | Per-item results |
| Server failed before processing any item | 5xx | Standard error body |
For mixed outcomes, 200 with a per-item results array is usually enough. 207 Multi-Status comes from WebDAV, where it is defined with an XML body. It is still reasonable for JSON APIs because it tells a reader without the body that individual outcomes differ. That signal matters in practice. Gateways, dashboards and alerting rules often count any 2xx as full success, so a 200 can hide a batch in which 40 of 50 items failed. Whichever code you choose, emit per-item failure counts as metrics.
Do not return 400 or 500 when some items committed. Clients and proxies read those codes as "nothing happened." Some clients will retry the entire batch, and any non-idempotent items that already succeeded will then be duplicated.
How do you implement limits, idempotency and retries for batch endpoints?
Cap item count and payload size
Set two limits: a maximum number of items per batch and a maximum request body size. The item cap bounds the time spent inside a single transaction or worker. The size cap protects parsers and memory. When a request exceeds either limit, reject the whole request with 413 Content Too Large, or with 400 and a specific error code such as batch_too_large. Do not truncate silently. If you process the first 500 items and drop the rest, the client believes everything was handled. Publish both limits in your documentation and in the error body so clients can split their work into correctly sized chunks without guessing.
Idempotency at two levels
A batch-level idempotency key lets a client safely retry a request whose response was lost. The server replays the stored result and does not execute the batch again. That covers only exact resends. Partial failures need stable per-item keys, either client-supplied IDs or a key on each item. With per-item keys, the client can resend only the failed items in a new batch, and if one of them had in fact succeeded, the server recognizes the key and does not create a duplicate. Both kinds of key cost storage. Each stored key holds a request fingerprint and a result, so pick a retention window that is longer than any realistic retry horizon and document it.
Rate limits and quotas
Decide whether a batch counts as one request or as N operations. If it counts as one, clients can use batching to get around per-operation limits. Many APIs therefore charge per item against quotas and per request against connection-level rate limits. Whichever model you choose, document it explicitly.
import hashlib, json
MAX_ITEMS = 500 # published in docs and echoed in error bodies
MAX_BODY_BYTES = 1_048_576 # 1 MiB; in production also enforce at the proxy/parser
batch_keys = {} # Idempotency-Key -> (fingerprint, response); purge after retention window
item_keys = {} # client_id -> result of a *successful* item; same retention policy
def handle_batch(raw_body: bytes, idem_key: str, account):
# Reject oversize batches whole. Never process a prefix and drop the rest.
if len(raw_body) > MAX_BODY_BYTES:
return 413, {"error": "payload_too_large", "max_body_bytes": MAX_BODY_BYTES}
items = json.loads(raw_body)["items"]
if len(items) > MAX_ITEMS:
return 400, {"error": "batch_too_large", "max_items": MAX_ITEMS, "received": len(items)}
# Batch-level key: an exact resend replays the stored response.
fingerprint = hashlib.sha256(raw_body).hexdigest()
if idem_key in batch_keys:
stored_fp, stored_resp = batch_keys[idem_key]
if stored_fp != fingerprint: # same key, different body: client bug
return 422, {"error": "idempotency_key_reused"}
return stored_resp
charge_quota(account, units=len(items)) # documented choice: quota is per item
results = []
for item in items:
cid = item["client_id"] # stable per-item key
if cid in item_keys: # succeeded in an earlier batch: no duplicate
results.append(item_keys[cid]); continue
try:
res = {"client_id": cid, "status": 201, "id": create_record(item)}
item_keys[cid] = res # store successes only, so failures can be resent
except ValueError as e:
res = {"client_id": cid, "status": 422, "error": str(e)}
results.append(res)
response = (207, {"results": results})
batch_keys[idem_key] = (fingerprint, response)
return response
A batch handler that rejects oversize requests with their limits in the error body, replays exact resends by batch idempotency key, skips items whose stable client_id already succeeded, and charges quota per item.
When should bulk processing be asynchronous with 202 Accepted?
A synchronous bulk call ties the client's connection to the slowest item in the batch. Switch to a job resource when any of these hold:
- Timeouts: worst-case processing time approaches the timeout of a load balancer, gateway or client in the path.
- Slow downstream calls: items fan out to slow or rate-limited downstream services.
- Batch size: batches are large enough that retrying after a dropped connection would redo minutes of work.
In the job-resource pattern, the server validates the request, persists a job and returns 202 Accepted with a Location header pointing at it. When the job finishes, it exposes the same per-item result format the synchronous endpoint would have returned, so clients parse one format.
| Step | From → To | Message |
|---|---|---|
| 1 | Client → API | POST /bulk with items and an idempotency key |
| 2 | API → Queue | Persist job, enqueue work |
| 3 | API → Client | 202 Accepted, Location: /jobs/{id} |
| 4 | Client → API | GET /jobs/{id} |
| 5 | API → Client | 200, state: running, Retry-After |
| 6 | Worker → Store | Write per-item results, mark job complete |
| 7 | Client → API | GET /jobs/{id} |
| 8 | API → Client | 200, final state plus per-item results |
The extra machinery has costs:
- Job states: You need explicit states (queued, running, succeeded, partially succeeded, failed) and transitions that survive a worker crash mid-batch.
- Result retention: You must store results and expire them. Document how long they are kept and what a client receives after expiry.
- Polling or callbacks: A
Retry-Afterheader paces polling. Callbacks avoid polling but need signing, retries and handling for unreachable receivers. - Testing: Test worker restarts, duplicate submissions that return the existing job, and expired results.
+--------+ +-------+ +-------+ +--------+ +-------+
| Client | | API | | Queue | | Worker | | Store |
+--------+ +-------+ +-------+ +--------+ +-------+
| | | | |
| 1 POST /bulk + idempotency key| | | |
|------------------------------>| | | |
| | 2 persist | | |
| | + enqueue | | |
| |------------>| | |
| 3 202 Accepted | | | |
| Location: /jobs/{id} | | | |
|<------------------------------| | | |
| | | dequeue | |
| | |--------->| |
| 4 GET /jobs/{id} | | | |
|------------------------------>| | | |
| 5 200, state: running | | | |
| Retry-After | | | |
|<------------------------------| | | |
| | | | 6 results |
| | | | + complete|
| | | |---------->|
| 7 GET /jobs/{id} | | | |
|------------------------------>| | | |
| 8 200, final state | | | |
| + per-item results | | | |
|<------------------------------| | | |
| | | | |
Asynchronous bulk processing with a job resource: the API persists and enqueues the job, returns 202 Accepted with a Location header, and the client polls the job, paced by Retry-After, until the worker has stored the per-item results.
When should you avoid bulk endpoints, and what are the alternatives?
A bulk endpoint is the wrong choice when clients rarely send more than a handful of items, because the extra contract, tests and documentation cost more than the round trips they save. It is also a poor fit when every item needs its own authorization decision, such as records owned by different tenants or users. Each item then fails on its own terms, and the bulk path turns into a harder version of the single-item API. Finally, avoid it if your team cannot keep it in step with the single-item endpoints. A bulk path that nobody maintains is a liability.
| Alternative | Fits when | Trade-off |
|---|---|---|
| Parallel single-item requests over HTTP/2 | Moderate volume, interactive clients | Multiplexing removes most connection overhead, but clients must cap concurrency and handle rate limits |
| File upload or import job | Large one-off loads, CSV or JSON exports | Needs a job status resource and a downloadable error report |
| Event or queue ingestion | Continuous high-volume producers | Clients see eventual consistency, and you need dead-letter handling |
Bulk handlers often skip the per-resource code to save time. Validation rules, permission checks and webhook emission then drift apart, and bugs show up in only one path. Prevent this by routing every item through the same domain service the single-item endpoint uses.
Log and emit metrics per item, keyed by the client's item identifier, and publish the response contract. Support engineers can then answer "what happened to record 4,812?" from the logs without reproducing the whole batch. Clients can also reconcile results themselves instead of opening a support ticket.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to rest api bulk operations, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a route's URL or method automatically notifies the services that consume it — no hunting through repos for callers.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Consumers learn about a breaking change at review time rather than from production errors.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.