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/42What 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
- 400 Bad Request
- 401 Unauthorized
- 402 Payment Required
- 403 Forbidden
- 404 Not Found
- 405 Method Not Allowed
- 406 Not Acceptable
- 407 Proxy Authentication Required
- 408 Request Timeout
- 409 Conflict
- 410 Gone
- 411 Length Required
- 413 Content Too Large
- 414 URI Too Long
- 415 Unsupported Media Type
- 416 Range Not Satisfiable
- 417 Expectation Failed
- 418 (Unused)
- 419 Page Expired
- 421 Misdirected Request
- 422 Unprocessable Content
- 423 Locked
- 424 Failed Dependency
- 425 Too Early
- 426 Upgrade Required
- 428 Precondition Required
- 429 Too Many Requests
- 431 Request Header Fields Too Large
- 444 No Response
- 451 Unavailable For Legal Reasons
- 494 Request Header Too Large
- 495 SSL Certificate Error
- 496 SSL Certificate Required
- 497 HTTP Request Sent to HTTPS Port
- 499 Client Closed Request