Errorspartial failurebatchatomicitymulti-statusper-item results

Partial Failure: When 3 of 5 Succeed

A batch request where some items succeed and some fail has no honest single status code. The contract must choose — atomic, best-effort with a per-item report, or a mix — and say so before the first consumer assumes the wrong one.

Follow the failure

Frame the contract

API design starts with a consumer, a design question and a guarantee — never with a URL.

Design question
When one request carries many operations and only some succeed, what does the response claim — and what is the caller supposed to do next?
Consumers
A mobile app syncing 40 offline edits in one call; a partner importing 5,000 catalog items nightly; an admin UI bulk-archiving tickets; any client that must decide, from your response, which items to fix, which to retry, and which to never send again.
The promise
A well-designed batch contract states its atomicity up front — all-or-nothing, or independent items — and on partial success returns a machine-readable per-item result set that makes "retry exactly the failures" a safe, mechanical operation.
RequirementConsumersResource ModelStyleContractValidationAuthorizationErrorsIdempotencyPaginationVersioningObservabilityEvolutionTrade-offs

The lie of the single status code

HTTP gives one status line per response, and a batch outcome does not fit in it. Return 200 for 3-of-5 and the naive client concludes everything worked — two silent losses that surface weeks later as missing data. Return 400 and the naive client retries the whole batch — re-executing the 3 successes, which is duplicate side effects unless every item is idempotent. Neither code is *wrong* by the RFC; both are wrong as communication, because the interesting information is per-item and the status line is per-request.

So the first contract decision is not a status code but a semantics: is this batch atomic (all succeed or none do — the request behaves like a transaction) or independent (items succeed and fail separately, and the response reports each)? Everything else follows from that choice: an atomic batch can use plain 200/4xx honestly, because there is no partial state to report; an independent batch needs a per-item results structure no matter which top-level status you pick — 200-with-report and 207-style multi-status are both workable, and the body, not the status, carries the truth.

What is never workable is deciding implicitly. A consumer who assumes atomicity against a best-effort API loses data silently; one who assumes best-effort against an atomic API writes pointless per-item retry logic. The assumption is invisible in the happy path — every item succeeds in the demo — and surfaces in production, at scale, on the day it is most expensive to discover (see What an API Contract Actually Is).

Best-effort execution behind an atomic-looking response
1POST /contacts/batch (5 items)
2200 OK
3{ "imported": 3 }
4
5# which 3? the client sent 5 —
6# are 2 queued? failed? duplicates?
7# the only recovery is re-sending all 5,
8# which duplicates the 3 that worked
Independent items, reported independently
1POST /contacts/batch (5 items, atomicity: independent)
2200 OK
3{
4 "results": [
5 { "index": 0, "status": "created", "id": "ct_91" },
6 { "index": 1, "status": "created", "id": "ct_92" },
7 { "index": 2, "status": "failed",
8 "error": { "code": "validation_failed",
9 "details": { "fields": [ { "path": "email",
10 "rule": "format" } ] } } },
11 { "index": 3, "status": "created", "id": "ct_93" },
12 { "index": 4, "status": "failed",
13 "error": { "code": "duplicate_email", "retryable": false } }
14 ],
15 "summary": { "created": 3, "failed": 2 }
16}

The good side makes recovery mechanical: results align to input by index, each failure carries the same error envelope as a single-item call (see The Error Model: Structure Over Apology), and retryable distinguishes "fix item 2" from "drop item 4". The bad side's {"imported": 3} forces the client to choose between data loss and duplication.

Choosing the semantics: what the domain can afford

Atomicity is a spectrum with real costs at both ends, and the domain — not elegance — chooses. All-or-nothing is the right promise when items are correlated and partial state is dangerous: a money transfer's debit and credit, an order's line items. It is cheap when the batch maps to one database transaction, and it gets expensive fast when items fan out across services — distributed atomicity is a saga or a workflow, not a flag on an endpoint (see There Is No Transaction Across APIs).

Independent execution is the right promise when items are genuinely unrelated — contact imports, notification sends, bulk tag operations — because one bad row failing 4,999 good ones is punishment, not integrity. Its cost is pushed to the client: every consumer must now handle the partial case, which is why the per-item report and its ergonomics are the bulk of the design. A useful middle exists: validate atomically, execute independently — reject the whole batch on structural problems (malformed items, over the size limit) so garbage fails fast, then execute the valid items independently. Callers get cheap early failure *and* independent progress.

Whatever you choose, bound it. A batch endpoint without a size limit is an Unbounded Collections: The Anti-Pattern With a Fuse problem in reverse: a 100,000-item batch is a slow request, a memory spike, a giant response, and — under independent semantics — a report the client must process item by item. Limits, per-item timeouts and the batch's interaction with rate limiting (does a 500-item batch cost 1 request or 500?) are all contract clauses (see Batch APIs and Partial Failure and The Rate-Limit Contract).

Three semantics, honestly priced
SemanticsPromise to the callerProvider costCaller costFits when
AtomicAll or nothing; no partial state existsA transaction — hard across servicesSimple: retry the whole batchCorrelated items; partial state is dangerous
Independent + reportEach item stands alone; full per-item resultsResult tracking, report designMust handle partial outcomes everywhereUnrelated items; one bad row must not block 4,999
Validate-atomic, execute-independentGarbage fails fast; valid items proceed aloneTwo-phase boundarySame as independent, minus structural noiseBig imports with occasional bad rows

Retrying the failures — and only the failures

The per-item report exists to make one loop safe: for each failed item: fix or retry. That loop has the same requirements as any retry (see Retryability: Telling Clients What To Do Next): each item failure needs the standard error envelope with a retryable signal, because a batch mixes permanent failures (validation_failed — fix it) with transient ones (dependency_timeout — resend as-is). And resending must be safe against the classic batch race: the item that *reported* failure but *actually* succeeded — a timeout between the item's database commit and the report assembly. Per-item idempotency (an item_key the caller supplies, or natural keys like email) is what makes resending failures a no-op instead of a duplicator (see Idempotency Keys: The Mechanism).

Very large batches change shape entirely. A 5,000-item synchronous batch holding a connection for two minutes is a timeout generator; past some size the honest contract is asynchronous — accept the batch with 202, expose progress as a job, deliver the per-item report as a downloadable result (see The Async Job Pattern). The partial-failure semantics do not change; only the delivery of the report does. Design the report format once and reuse it in both the synchronous and asynchronous shapes.

  • Per-item errors reuse the single-item error envelope — batch handling should not be a second error dialect.
  • Results align to inputs mechanically: index for positional inputs, caller-supplied item_key when order is unreliable.
  • Per-item idempotency makes "resend the failures" safe against the reported-failed-but-committed race.
  • Past a size threshold, the same report moves to an async job; the semantics survive, the transport changes.

Key points

  • A single status code cannot describe a mixed outcome; the body's per-item report carries the truth, whatever the status line says.
  • The core contract decision is atomic vs independent — and it must be explicit, because consumers who guess wrong either lose data or duplicate it.
  • Atomicity across services is a distributed-transaction problem; do not promise it as a flag on an endpoint.
  • "Validate atomically, execute independently" gives fast failure on garbage and progress on the rest — the practical middle for imports.
  • Per-item results need the standard error envelope, retryable, stable input alignment, and per-item idempotency so retrying failures is mechanical and safe.
  • Bound the batch: size limits, rate-limit accounting, and an async shape for reports too big to wait for.

Follow the failure

How the contract fails or gets misused, hop by hop — and what it costs when it completes.

  1. 1
    Team → API: ships POST /items/batch returning 200 {"imported": N} — the count seemed like enough.
  2. 2
    Consumer → API: assumes all-or-nothing (the demo never failed), treats any 200 as complete success.
  3. 3
    Production → batch: item 1,204 of 5,000 hits a duplicate; 4,999 import, one vanishes; the response says {"imported": 4999}.
  4. 4
    Consumer → reconciliation: weeks later the missing record surfaces in an audit; the client team re-runs the whole import "to be safe".
  5. 5
    Re-run → API: without per-item idempotency, 4,999 duplicates are created; the cleanup costs more than the original feature.
What breaks
  • Silent data loss: partial successes reported as plain success are discovered by downstream audits, not by the caller.
  • Duplicate side effects: whole-batch retries re-execute the successes — at their worst when items are emails, charges or jobs.
  • Consumer divergence: each client invents its own recovery (re-send all, diff against a list call, ignore) and support inherits the zoo.

Design, observe, evolve

A contract decision is incomplete until you know how you would notice it failing and how it changes later.

Design the contract
  • • Declare atomicity per batch endpoint as an explicit, documented contract clause — and test the partial path, not just the happy one.
  • • Return per-item results with input alignment, the standard error envelope and `retryable` per failure; keep the format identical between sync and async delivery.
  • • Support per-item idempotency (caller-supplied `item_key` or a natural key) so retrying reported failures is safe against the committed-but-reported-failed race.
  • • Cap batch size and define its rate-limit accounting; route oversized batches to the async job shape instead of stretching timeouts.
Observe in production
  • • Track partial-failure rate (batches with ≥1 failed item) and failed-item rate separately; the first measures caller experience, the second data quality.
  • • Watch for whole-batch resubmissions shortly after partial results — the signature of clients that are not consuming the per-item report.
  • • Alert when per-item failure clusters by error code within a batch window: 4,000 `validation_failed` from one consumer is their schema drift, not 4,000 typos.
Evolve without breaking
  • • New per-item statuses (e.g. `skipped`, `queued`) are additive only if clients were told to treat unknown statuses as non-success and consult `error` — state that rule from day one.
  • • Moving an endpoint from sync report to async delivery is a new shape, not a mutation: keep the sync path during migration and share the report format.
  • • Tightening atomicity (best-effort → atomic) changes recovery semantics for deployed clients; it is a breaking behavioral change even though the schema is identical.
What it costs
  • • Independent semantics push complexity to every consumer forever; atomic semantics concentrate it in the provider once — when the domain allows a choice, that asymmetry is the tiebreaker.
  • • Per-item reports are large: a 5,000-item batch returns 5,000 results even on success, which costs bandwidth and parsing unless you offer a failures-only response mode.
  • • Per-item idempotency keys add caller-side bookkeeping; skipping them keeps the API simpler and makes every retry a judgment call — an honest trade only for read-only or naturally idempotent batches.

Misconceptions

Claim
“Return 207 Multi-Status and the partial-failure problem is solved.”
Reality
The status code is the least of it. Without input alignment, per-item error envelopes, retryability signals and per-item idempotency, a 207 is just a 200 that warns you the body is complicated. The design work is the report and the recovery loop it enables.
Claim
“Make batches atomic and clients never deal with partial state.”
Reality
True exactly as far as one transaction reaches. Across services, atomicity means sagas and compensation — weeks of work, new failure modes — and one poisoned item now blocks every good one. Atomicity is a promise with a price; for independent items it buys punishment, not integrity.
Claim
“Clients can diff against a GET to figure out what actually happened.”
Reality
Reconstruction-by-listing is racy (concurrent writers), expensive (pagination over big collections), and sometimes impossible (side effects like emails are not listable). If the client's recovery plan is a diff, the batch response has failed at its one job: saying what happened.