# How to report partial failure in a batch endpoint

> Return one outcome per item when a batch of writes half succeeds, name each failed item by the id the client sent, and never answer 200 when nothing was written.

Source: https://voxgig.com/howto/report-partial-failure-in-a-batch-endpoint

- Audience: api-producer
- Level: intermediate
- Verified: 2026-09-24
- Published: 2026-09-24

## Short answer

Give every item a client-supplied id and answer with one outcome per item, each failure an RFC 9457 problem document. Use 207 when some items failed, 200 when all succeeded, and a 4xx when none did, so a client that reads only the status still learns the truth. Reserve all-or-nothing rejection for a store that can roll the whole batch back.

---
## You will need

Node 22 or later, and a batch endpoint that accepts an array of items. The per-item failures
below are [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html) problem documents, and the 207
status comes from [RFC 4918 section 13](https://www.rfc-editor.org/rfc/rfc4918.html#section-13),
which is the WebDAV specification.[^1] Decide the status for each single-item error first; the batch
status is derived from those.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [207 Multi-Status](https://www.rfc-editor.org/rfc/rfc4918.html#section-13) | Independent writes where most succeed and the client can retry the rest | A body every client has to parse before it knows anything, and a 2xx that still needs reading | A half-written batch is a corrupt state in your store |
| [All-or-nothing rejection](https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_TransactWriteItems.html) | A transactional store, and items that only make sense together | One bad item rejects every good one, so a large batch rarely lands on the first try | The items are independent and the batch is big |
| [Elasticsearch `_bulk` response](https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-bulk) | Clients generated from the schema, which always read the `errors` flag | A 200 on a batch where nothing was written, which a status-only client reads as success | Clients you do not control, which check the status and stop |
| [Microsoft Graph `$batch`](https://learn.microsoft.com/en-us/graph/json-batching) | Requests inside the batch depend on each other and a failure has to cascade | A 200 envelope around per-request statuses, and dependents that fail with 424 | The items are independent and a cascade refuses work that could have landed |

Per-item results make the client do more work, and they are what keeps a large batch practical:
a thousand lines with one bad row lands nine hundred and ninety-nine of them. All-or-nothing is
simpler to reason about and needs a store that can roll back, which is why DynamoDB documents it
as a transaction rather than a batch. The errors-flag shape is the per-item shape with the batch
status removed, and the removal is the pitfall this page is about.

## Key every item by the id the client sent

Position is the obvious key and the wrong one. The server may skip a duplicate, reject an item
before the array is walked, or reorder for the store. After any of those, the client's index into its own array no longer
points at the item that failed. An id the client chose survives all of that, and it
doubles as the item's idempotency key when the client sends it again.

```ts title="batch.mjs"
function outcomes(items, store, { write }) {
  const seen = new Set()
  return items.map((item) => {
    if (item === null || typeof item !== 'object') {
      return { id: null, status: 422, problem: { type: `${PROBLEMS}/item-not-object`, title: 'Item is not an object', status: 422 } }
    }
    let problem = validate(item)
    if (!problem && seen.has(item.id)) {
      problem = { type: `${PROBLEMS}/duplicate-id`, title: 'Item id repeated in this batch', status: 422 }
    }
    seen.add(item.id)
    if (problem) return { id: item.id ?? null, status: problem.status, problem }
    if (store.has(item.id)) return { id: item.id, status: 200 }
    if (write) store.set(item.id, { amount: item.amount })
    return { id: item.id, status: 201 }
  })
}
```

Each outcome carries the id, a status, and, on failure, a problem document with a `type` URI a
client can switch on. The per-item status is the one the same write would have earned as a single
request, which is what lets a client reuse its single-request error handling item by item. The
outer response is `application/json`: the `application/problem+json` media type describes one
document, and here there are several inside a list. An item that is not an object gets a failed
outcome of its own instead of throwing the batch away. An id already in the store answers 200
without a second write.

## Pick the batch status from the item outcomes

The batch status is derived, not chosen. An empty batch is refused first, because it has
written nothing, and after that the rule has three branches.

```ts title="batch.mjs"
export function multiStatus(items, store) {
  if (items.length === 0) return { status: 422, body: EMPTY }
  const results = outcomes(items, store, { write: true })
  const failed = results.filter((r) => r.problem).length
  const status = failed === 0 ? 200 : failed === results.length ? 422 : 207
  return { status, body: { results } }
}
```

The third branch is the one that matters. 207 sits in the 2xx class, and
[`Response.ok`](https://developer.mozilla.org/en-US/docs/Web/API/Response/ok) is true for any status from 200 to 299. A client that checks `ok` and moves on treats a batch where every item
failed as a success. Elasticsearch's `_bulk` answers 200 with `errors: true` and leaves the reading to you. Amazon's SQS reference tells its callers, in so many words, to check for
batch errors even when the call returned a 200.[^2] A 422 for the all-failed case costs nothing
and reaches the clients that never read the body.

## Check it worked

Three ledger lines, the second with a negative amount, through all three shapes. Then a batch
where every item fails, then a retry of only the failed item.

```bash
node demo.mjs
```

```text output
item 2 of 3 fails
multi-status  status 207  written 2 of 3  failed ln_b
errors-flag   status 200  written 2 of 3  failed ln_b
atomic        status 422  written 0 of 3  failed ln_b

every item fails
multi-status  status 422  res.ok false  written 0
errors-flag   status 200  res.ok true  written 0
atomic        status 422  res.ok false  written 0

the client retries only what failed, under multi-status
first call 207, retried ln_b, second call 200
ledger holds 3 lines: ln_a,ln_c,ln_b

the failing item, as the client sees it
{
  "id": "ln_b",
  "status": 422,
  "problem": {
    "type": "https://api.example.com/problems/amount-not-positive",
    "title": "Amount must be greater than zero",
    "status": 422,
    "detail": "amount was -5"
  }
}
```

The middle block is the whole argument in three lines. Nothing was written under any shape, and
only the errors-flag shape says `res.ok true`. The retry block is what per-item results buy: the
client resent `ln_b` alone, the second call was a plain 200, and the ledger holds three lines with
no duplicate of `ln_a` or `ln_c`.

The test pins the id rule by running the same batch reversed and asserting the failure is still
`ln_b`.

```bash
node --test batch.test.mjs
```

```text output
1..9
# tests 9
# suites 0
# pass 9
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 121.023088
```

## When it goes wrong

Every item failed and the client reported success. The endpoint answered 200 with a flag in the
body, and the client checked the status and stopped.[^3] Derive the batch status from the outcomes and
answer a 4xx when nothing was written.

The client retried the wrong item. The response reported failures by position, the server had
dropped a duplicate before validating, and index 1 in the response was index 2 in the request.
Report the client's own id and never a position.

A retry after a 207 wrote two lines twice. The client resent the whole batch instead of the failed
items, and the endpoint had no idempotency rule per item. Treat the client's item id as the key
for that item, so a resend of a line already written answers 200 and leaves the stored line alone, the way an
[`Idempotency-Key`](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/)
does for a single request.

## When not to do this

Do not report per-item outcomes from a transactional store. If two of three lines cannot exist
without the third, a 207 describes a corrupt ledger accurately, which is no comfort. Validate
everything, write nothing on any failure, and answer a 422 that names the first failing item and
lists the rest, as the `atomic` shape here does.

Do not go all-or-nothing on a large batch of independent items. One malformed row in a thousand
sends the other nine hundred and ninety-nine back to the client. A client that generates rows
from user data will rarely get a thousand clean ones on the first try.

Do not answer 207 for a batch where every item succeeded. The status exists to say that the body
needs reading, and a client that learns to ignore it on the happy path ignores it on the other
one.

Do not let a batch grow without a cap. A per-item response is as large as the batch, and a
client that sends ten thousand items gets ten thousand outcomes back in one body.

## Related how-tos

- [Return validation errors a client can map to a form](/howto/field-level-validation-errors-clients-can-map)

- [Evolve an error contract without breaking clients](/howto/evolve-an-error-contract-without-breaking-clients)

- [Make queue consumer writes safe under redelivery](/howto/queue-consumer-writes-under-at-least-once)

## Last verified

Verified 2026-09-24 against Node 22.22.2. Both output blocks are what the preceding command
printed. The three shapes run as functions over an in-memory store, called directly rather than
through an HTTP server.

[^1]: 207 is a WebDAV status. [RFC 4918](https://www.rfc-editor.org/rfc/rfc4918.html#section-13)
defines the Multi-Status body as XML with a `multistatus` root element, and
[RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html) does not define 207 at all; the
[IANA status code registry](https://www.iana.org/assignments/http-status-codes) lists it against
RFC 4918. JSON APIs borrowed the number and left the body behind, which is a tidy example of how
a status code escapes its specification. 424 Failed Dependency, which Microsoft Graph uses for a
request whose parent failed, comes from [the same document](https://www.rfc-editor.org/rfc/rfc4918.html#section-11.4).

[^2]: The sentence is in the
[SendMessageBatch reference](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/APIReference/API_SendMessageBatch.html).
Because a batch can mix successful and unsuccessful actions, it says, you should check for batch
errors even when the call returns an HTTP status code of 200. A vendor documenting the failure mode of
its own response shape is rarer than it should be, and the sentence has outlived several
generations of client libraries that did not read it.

[^3]: Microsoft's batching guide says a 200 on the batch response does not indicate that the individual requests inside it succeeded. It then describes a batch in
which requests 2 and 3 failed with 403 and request 4 with 405, all under that 200. The
[guide](https://learn.microsoft.com/en-us/graph/json-batching) is one of the few that puts the
mixed result in its own worked example rather than in a footnote.