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
Who raised the objection A request carrying If-Match that no longer matches yields 412. A request with no precondition on a resource that requires one yields 428. A well-formed request that the server finds incompatible with current state yields 409. write request well-formed body carries If-Match no precondition state check validator no longer matches the client's own conditionfailed 412 Precondition Failed resource demands a condition none was supplied 428 Precondition Required server finds the request incompatible with state 409 Conflict

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.

Client recovery paths for 412 and 409 After a 412 the client refetches the resource, merges changes and retries with the new validator. After a 409 the client branches on the conflict reason: duplicate key and invalid transition are terminal, dependent exists requires cleanup first, and in flight allows a short delayed retry. after 412 after 409 read current_etag from the body refetch the representation merge local and remote changes retry with the new If-Match duplicate_key → use the existing resource invalid_transition → surface to the user dependent_exists → delete dependents, retry in_flight → short delay, bounded retry the reason, not the status code, decides the action

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:

What each code tells the client about side effects A 412 proves the precondition was evaluated before any state change, so nothing happened. A 409 varies by reason, which is why the body must state whether the request was applied. 412 Precondition Failed 409 Conflict evaluated before any state change nothing was applied, always safe to refetch, merge and retry current validator returned for recovery duplicate_key: nothing applied in_flight: another request may be applying it invalid_transition: nothing applied, never will be the body must say which a status code cannot express side-effect status — an extension member can

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.