428 Precondition Required
Client error response defined by RFC 6585 §3.
Last updated July 27, 2026
Common causes at a glance
- A PUT or PATCH sent without an If-Match header on an API that demands one
- A client that discarded the ETag it received from the earlier GET
- An intermediary or CDN stripping If-Match from the forwarded request
- A DELETE against an API requiring conditional deletes to prevent races
Reproduce it
curl -i -X PUT -H 'Content-Type: application/json' -d '{"title":"new"}' https://api.example.com/v1/docs/42What 428 Precondition Required means
The scenario 428 prevents is two clients doing read-modify-write on the same
resource. Both GET version 5, both edit locally, both PUT. The
second write silently overwrites the first and nobody finds out. Requiring an
If-Match header carrying the ETag the client read means the second write
fails loudly with 412 instead.
428 is the bootstrap for that policy. A server that answered 200 to unconditional writes could never migrate to requiring them; answering 428 tells the client precisely what is missing and lets it correct itself in one round trip. Note the pairing: 428 means “you sent no precondition”, 412 means “you sent one and it did not hold”.
What the spec says
RFC 6585 §3 defines 428 and requires the response body to
explain how to resubmit successfully. It is explicitly not cacheable. The preconditions
themselves are RFC 9110 §13.1 — If-Match is
§13.1.1 and If-Unmodified-Since is
§13.1.4 — with evaluation order in §13.2.
Note that 428 is optional: a server may accept unconditional writes and live with the
race. If you do require preconditions, RFC 9110 §8.8.3 defines the
ETag clients have to echo back.
What actually causes it
The usual failure is a client that never captured the ETag in the first
place. Reading a resource through one code path and writing it through another loses the
header somewhere in between, and generated SDKs are especially prone to it because the
model object carries the fields but not the response metadata. The second cause is mangled
validators: a compressing proxy that rewrites a strong ETag into a weak one breaks
If-Match, since weak validators are not usable for
§13.1.1 comparison. And a client that hardcodes
If-Match: * to get past the 428 has defeated the mechanism — that matches any
existing representation and reintroduces the lost update it was meant to prevent.
How to debug it
Do the two-step by hand. Fetch the resource, keep the ETag, send it back:
etag=$(curl -sSI https://api.example.com/v1/docs/42 | awk -F': ' '/^[Ee][Tt]ag/{print $2}')
curl -sSi -X PUT -H "If-Match: $etag" -H 'Content-Type: application/json' \
-d '{"title":"new"}' https://api.example.com/v1/docs/42
If that succeeds and the application still gets 428, the header is being lost between your code and the server; the header inspector makes the diff obvious. If you get 412 instead of 200, the mechanism is working and someone else changed the resource after your read: re-fetch, reapply, retry. That loop is safe, unlike retrying a 428 unchanged, which fails identically every time.
Working examples
curl
# Read the current validator.
curl -sSI https://api.example.com/v1/docs/42 | grep -i '^etag:'
# Write conditionally with it. 412 means someone beat you to it; 200 means you won.
curl -sSi -X PUT \
-H 'If-Match: "a1b2c3d4"' \
-H 'Content-Type: application/json' \
-d '{"title":"new"}' \
https://api.example.com/v1/docs/42
Python (requests)
import requests
url = "https://api.example.com/v1/docs/42"
current = requests.get(url, timeout=10)
etag = current.headers["ETag"] # keep this alongside the parsed body
resp = requests.put(
url,
json={**current.json(), "title": "new"},
headers={"If-Match": etag},
timeout=10,
)
# 428 = you forgot the header. 412 = the resource moved on; re-read and retry.
print(resp.status_code)
Node (fetch)
const url = "https://api.example.com/v1/docs/42";
const current = await fetch(url);
const etag = current.headers.get("etag");
const doc = await current.json();
const res = await fetch(url, {
method: "PUT",
headers: { "If-Match": etag, "Content-Type": "application/json" },
body: JSON.stringify({ ...doc, title: "new" }),
});
// Never substitute If-Match: * to silence a 428 — it defeats the whole point.
if (res.status === 412) console.warn("concurrent update, re-read and retry");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
- 412 Precondition Failed
- 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
- 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