409 Conflict vs 412 Precondition Failed
This page belongs to HTTP Status Code Mapping in the Error Contracts & Resilience Mapping reference. Both codes reject a well-formed request because of server state. The difference is who raised the objection: 412 means a condition the client attached evaluated false, while 409 means the server itself found the request incompatible with current state.
The decision trigger
| Situation | Code |
|---|---|
If-Match supplied and no longer matches |
412 |
If-Unmodified-Since supplied and the resource changed |
412 |
A write requires If-Match but none was supplied |
428 |
| Creating a resource whose unique key already exists | 409 |
| Cancelling an order that has already shipped | 409 |
| Two concurrent requests with the same idempotency key | 409 |
| A payload field violates a validation rule | 422 |
| Deleting a resource with dependents that must go first | 409 |
Spec snippet: distinct bodies, distinct affordances
# openapi.yaml (OpenAPI 3.1.0)
components:
schemas:
PreconditionFailedProblem:
allOf:
- $ref: "#/components/schemas/Problem"
- type: object
required: [current_etag]
properties:
type: { const: "urn:api:errors:v1:precondition-failed" }
current_etag: { type: string, example: '"v43"' }
supplied_etag:{ type: string, example: '"v42"' }
ConflictProblem:
allOf:
- $ref: "#/components/schemas/Problem"
- type: object
required: [conflict_reason, retryable]
properties:
type: { const: "urn:api:errors:v1:state-conflict" }
conflict_reason: { type: string, enum: [duplicate_key, invalid_transition, dependent_exists, in_flight] }
retryable: { type: boolean, description: True when refetching and retrying may succeed. }
current_state: { type: string, example: shipped }
The two extension sets differ because the next action differs. A 412 needs the current validator so the client can merge or overwrite; a 409 needs the reason and the current state so the client can decide whether the operation is possible at all.
// 412 — the client's precondition lost a race
{
"type": "urn:api:errors:v1:precondition-failed",
"title": "Document changed since the supplied ETag",
"status": 412,
"supplied_etag": "\"v42\"",
"current_etag": "\"v43\""
}
// 409 — the server refuses on state grounds
{
"type": "urn:api:errors:v1:state-conflict",
"title": "Order cannot be cancelled after shipping",
"status": 409,
"conflict_reason": "invalid_transition",
"current_state": "shipped",
"retryable": false
}
Step-by-step
Step 1 — Evaluate preconditions before business rules
// update-order.ts — precondition first, then state, then payload
export async function updateOrder(req: Request, res: Response) {
const ifMatch = req.header("If-Match");
if (!ifMatch) {
return problem(res, 428, {
type: "urn:api:errors:v1:precondition-required",
title: "If-Match is required",
detail: "Fetch the order and retry with its ETag in If-Match.",
});
}
const order = await orders.byId(req.params.orderId);
if (!order) return problem(res, 404, { type: "urn:api:errors:v1:not-found", title: "Order not found" });
if (`"v${order.version}"` !== ifMatch.trim()) {
res.setHeader("ETag", `"v${order.version}"`);
return problem(res, 412, {
type: "urn:api:errors:v1:precondition-failed",
title: "Order changed since the supplied ETag",
supplied_etag: ifMatch.trim(),
current_etag: `"v${order.version}"`,
});
}
if (!canTransition(order.state, req.body.state)) {
return problem(res, 409, {
type: "urn:api:errors:v1:state-conflict",
title: `Cannot move an order from ${order.state} to ${req.body.state}`,
conflict_reason: "invalid_transition",
current_state: order.state,
retryable: false,
});
}
// …validation of the payload itself (422) happens here…
}
The order matters: a stale If-Match should produce 412 even if the transition would also have been invalid, because the client’s view is out of date and refetching may change its intent entirely.
Step 2 — Say whether a retry is meaningful
409 is unusual among 4xx codes in that a retry sometimes is the right response — the conflicting condition may clear. But the status code cannot express which case applies, so the body must:
conflict_reason |
Retryable? | Client action |
|---|---|---|
duplicate_key |
no | use the existing resource, or choose a different key |
invalid_transition |
no | the state machine forbids it; do not retry |
dependent_exists |
after cleanup | delete dependents, then retry |
in_flight |
yes, after a delay | another request holds the operation; retry shortly |
The in_flight case is the one clients most often get wrong — it is the response to a concurrent duplicate under an idempotency key, described in Idempotency Key Storage, TTL and Eviction, and it deserves a short bounded retry rather than a permanent failure.
Step 3 — Handle both on the client
# conflict_handling.py — distinct recovery paths per code
import httpx
async def save_document(client: httpx.AsyncClient, doc_id: str, body: dict, etag: str) -> dict:
resp = await client.put(f"/documents/{doc_id}", json=body, headers={"If-Match": etag})
if resp.status_code == 412:
problem = resp.json()
# The server tells us the current validator — refetch and merge without guessing.
latest = await client.get(f"/documents/{doc_id}")
merged = merge_changes(base=body, theirs=latest.json())
return await save_document(client, doc_id, merged, latest.headers["ETag"])
if resp.status_code == 409:
problem = resp.json()
if problem.get("retryable"):
await asyncio.sleep(1)
return await save_document(client, doc_id, body, etag)
raise ConflictError(problem["title"], reason=problem.get("conflict_reason"))
resp.raise_for_status()
return resp.json()
Because the 412 body carries current_etag, the recursive call has everything it needs; without it, the client must issue an extra GET purely to learn a value the server already knew.
Standard compliance
| Code | Standard | Clause | Meaning |
|---|---|---|---|
409 Conflict |
RFC 9110 | §15.5.10 | Conflict with the target resource’s current state |
412 Precondition Failed |
RFC 9110 | §15.5.13 | A conditional request header evaluated false |
428 Precondition Required |
RFC 6585 | §3 | Server requires the request to be conditional |
If-Match |
RFC 9110 | §13.1.1 | Strong comparison against the current validator |
If-Unmodified-Since |
RFC 9110 | §13.1.4 | Weaker, timestamp-based precondition |
422 Unprocessable Content |
RFC 9110 | §15.5.21 | Content understood but semantically invalid |
RFC 9110 §15.5.10 also advises that a 409 response “SHOULD” contain enough information for the user to recognise the source of the conflict — which is the specification asking for exactly the conflict_reason and current_state members above.
The two codes also differ in what a client can conclude about the request it just sent:
Idempotency and safety implications
A 412 proves the write did not happen: the precondition was evaluated before any state change, so the client can safely re-derive its request and try again. A 409 is less uniform — for duplicate_key the write did not happen, while for in_flight an identical write may be happening right now in another request. That asymmetry is why the body should state the outcome explicitly rather than leaving clients to infer it, and why the conservative default for an ambiguous 409 on a non-idempotent operation is not to retry. The full classification is in Retryable vs Non-Retryable Errors.
SDK / codegen downstream effect
// Generated client with both problem schemas documented
- } catch (e) {
- if (e.status === 409 || e.status === 412) showToast("Conflict, please retry");
- }
+ } catch (e) {
+ if (e instanceof PreconditionFailedError) {
+ return refetchMergeRetry(e.problem.current_etag); // typed, actionable
+ }
+ if (e instanceof ConflictError && e.problem.retryable) {
+ return retryAfterShortDelay();
+ }
+ if (e instanceof ConflictError) {
+ return surfaceToUser(e.problem.conflict_reason, e.problem.current_state);
+ }
+ }
Documenting the two problem schemas separately is what makes the generated error classes distinct; a single shared Problem schema on both responses collapses them into one type and pushes the branching back into untyped property access.
Common mistakes
| Mistake | Correct approach |
|---|---|
409 when an If-Match precondition failed |
412 — the client’s own condition failed |
412 for a business-rule state conflict |
409 — the server raised the objection |
409 with no reason or current state |
Include conflict_reason and current_state |
412 without the current validator |
Return current_etag so the client can recover in one step |
Treating every 409 as terminal |
Say whether a retry is meaningful in the body |
Rejecting an unconditional write with 400 |
428 names the actual requirement |
| Evaluating business rules before preconditions | Preconditions first; the client’s view may be stale |
FAQ
Is a duplicate resource creation a 409 or a 422?
409. The request is syntactically valid and semantically well-formed — nothing about the payload is wrong — and the obstacle is the current state of the collection: something already occupies that identity. 422 is for content that violates a rule irrespective of server state, such as an end date before a start date, which would be invalid on an empty database. A useful test: if the same request would succeed on a fresh system, it is a state conflict and 409 applies; if it would fail there too, it is a validation failure and 422 applies.
Can a 409 be retried?
Sometimes, and that is precisely why the body must say so. Unlike most 4xx codes, the condition causing a 409 can change without the client changing anything — another request finishes, a dependent record is deleted, a workflow advances. But some conflicts are permanent: an order that has shipped will never become cancellable. Carry an explicit retryable boolean and a machine-readable conflict_reason, so a client library can implement a bounded retry for in_flight while surfacing invalid_transition immediately to the user rather than looping.
What should the response include when returning 412?
The current validator, at minimum. The client supplied an If-Match that no longer matches, so its next step is to refetch, reconcile and retry — and it needs the current ETag to do that. Returning it in both the ETag header and the problem body saves a round trip and makes the recovery loop mechanical. Echoing the supplied_etag alongside is cheap and pays for itself in debugging, because a mismatch between what the client thought it sent and what arrived usually means a proxy rewrote the header.
Related
- HTTP Status Code Mapping — up-link: the section mapping outcomes onto status codes
- Error Contracts & Resilience Mapping — up-link: the parent reference for failure contracts
- ETag and If-None-Match Conditional Requests — the preconditions that produce
412 - Choosing Between 400 and 422 for Validation Errors — the neighbouring distinction for payload problems
- Handling Idempotency Key Conflicts — the
in_flightconflict case in detail