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.
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
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).
1POST /contacts/batch (5 items)2→ 200 OK3{ "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 worked1POST /contacts/batch (5 items, atomicity: independent)2→ 200 OK3{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).
| Semantics | Promise to the caller | Provider cost | Caller cost | Fits when |
|---|---|---|---|---|
| Atomic | All or nothing; no partial state exists | A transaction — hard across services | Simple: retry the whole batch | Correlated items; partial state is dangerous |
| Independent + report | Each item stands alone; full per-item results | Result tracking, report design | Must handle partial outcomes everywhere | Unrelated items; one bad row must not block 4,999 |
| Validate-atomic, execute-independent | Garbage fails fast; valid items proceed alone | Two-phase boundary | Same as independent, minus structural noise | Big 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:
indexfor positional inputs, caller-supplieditem_keywhen 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.
- 1Team → API: ships
POST /items/batchreturning200 {"imported": N}— the count seemed like enough. - 2Consumer → API: assumes all-or-nothing (the demo never failed), treats any 200 as complete success.
- 3Production → batch: item 1,204 of 5,000 hits a duplicate; 4,999 import, one vanishes; the response says
{"imported": 4999}. - 4Consumer → reconciliation: weeks later the missing record surfaces in an audit; the client team re-runs the whole import "to be safe".
- 5Re-run → API: without per-item idempotency, 4,999 duplicates are created; the cleanup costs more than the original feature.
- 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.
- • 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.
- • 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.
- • 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.
- • 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.