Skip to content
Control Plane Labs

412 Precondition Failed

Client error response defined by RFC 9110 §15.5.13.

Last updated July 27, 2026

Common causes at a glance

  • Two clients writing the same resource, so the second If-Match ETag is stale
  • A cached or hardcoded ETag reused long after the resource changed
  • If-Unmodified-Since compared against a timestamp with one-second granularity
  • A proxy or CDN rewriting or stripping the ETag so it never matches the origin's

Reproduce it

curl -i -X PUT -H 'If-Match: "v3"' -d '{"name":"x"}' https://api.example.com/v1/items/42

What 412 Precondition Failed means

412 is the failure half of optimistic concurrency control. A client reads a resource, keeps the ETag, and later sends the update back with If-Match: "thatetag". If somebody else wrote to the resource in between, the stored entity tag no longer matches and the server rejects the write instead of silently clobbering the other party’s change. That is the entire mechanism, and it is the correct way to prevent lost updates over a stateless protocol.

The code does not mean something is broken. It is the system working: your view of the resource is stale, so re-read it, reconcile, and retry. Do not confuse it with 409 Conflict, which describes a conflict in the request’s own semantics rather than a failed precondition.

What the spec says

RFC 9110 §15.5.13 defines the code, and §13 defines conditional requests in full. The important part is the evaluation order in §13.2.2: If-Match is checked first, and a failure there produces 412. If-Unmodified-Since is only consulted when If-Match is absent, and it likewise yields 412. A failing If-None-Match behaves differently: for GET and HEAD it returns 304, and only for state-changing methods does it produce 412.

What actually causes it

Real 412s split into genuine races and self-inflicted ones. The genuine race is two writers on the same record, and the answer is to re-fetch and retry with backoff. The self-inflicted ones are more common: an ETag captured at application startup and reused for the process lifetime, a client comparing a weak validator (W/"abc") where the server requires a strong one, or a compression layer that rewrites the body and therefore the ETag on the way out, so the tag the client holds never matches what the origin computed. If-Unmodified-Since is worse still: HTTP dates have one-second resolution, so two writes inside the same second are indistinguishable. Prefer entity tags.

How to debug it

Read the current validator first, then replay the write with it:

curl -sI https://api.example.com/v1/items/42 | grep -i '^etag'
curl -i -X PUT -H 'If-Match: "v3"' -H 'Content-Type: application/json' \
-d '{"name":"x"}' https://api.example.com/v1/items/42

If a HEAD straight before the PUT still yields 412, the ETag you are sending is not the one the origin compares against; check for a W/ prefix, missing quotes, or a CDN that recomputes tags after compression. Comparing origin and edge through the header inspector shows a rewritten validator immediately. Once that is ruled out, treat remaining 412s as real contention and instrument the retry rate: a steady background rate is normal, a spike means two writers are fighting over one row.

Working examples

curl

# 1. Read the current entity tag.
curl -sI https://api.example.com/v1/items/42 | grep -i '^etag'

# 2. Conditional write. Quotes are part of the ETag syntax; keep them.
curl -i -X PUT \
  -H 'If-Match: "v3"' \
  -H 'Content-Type: application/json' \
  -d '{"name":"updated"}' \
  https://api.example.com/v1/items/42

Python (requests)

import requests

url = "https://api.example.com/v1/items/42"

def update(new_name, attempts=3):
    for _ in range(attempts):
        current = requests.get(url, timeout=10)
        etag = current.headers["ETag"]
        resp = requests.put(
            url,
            headers={"If-Match": etag, "Content-Type": "application/json"},
            json={**current.json(), "name": new_name},
            timeout=10,
        )
        if resp.status_code != 412:
            return resp
        # Somebody else won the race; re-read and try again.
    raise RuntimeError("gave up after repeated 412 Precondition Failed")

print(update("updated").status_code)

Node (fetch)

const url = "https://api.example.com/v1/items/42";

const current = await fetch(url);
const etag = current.headers.get("etag");
const body = await current.json();

const res = await fetch(url, {
  method: "PUT",
  headers: { "If-Match": etag, "Content-Type": "application/json" },
  body: JSON.stringify({ ...body, name: "updated" }),
});

if (res.status === 412) {
  console.log("stale ETag", etag, "- re-read and retry");
} else {
  console.log(res.status, res.headers.get("etag"));
}

Reference and tooling

The authoritative list of assigned status codes is theIANA HTTP Status Code Registry. For a searchable copy with the semantics summarised, use theHTTP status code reference on this site. To see exactly what a server is sending, theheader inspector shows the raw status line and response headers, and thecurl builder assembles the request flags for you.

Other 4xx codes