409 Conflict
Client error response defined by RFC 9110 §15.5.10.
Last updated July 27, 2026
Common causes at a glance
- Concurrent writers producing a lost update on the same resource
- A unique constraint violation on email, username, or slug
- Creating a resource that already exists at a client-chosen identifier
- A state machine transition that is invalid from the resource's current state
Reproduce it
curl -i -X PUT -H 'If-Match: "v3"' -d '{"title":"new"}' https://api.example.com/v1/docs/42What 409 Conflict means
409 covers state conflicts, and the canonical one is a lost update. Two clients read version 3 of a document, both edit it, both write back. Without coordination the second write silently destroys the first. With optimistic concurrency it is rejected, and 409 is how the server says so.
The other everyday use is uniqueness: creating a user with an email that already exists, or a repository with a name already taken. That is a conflict with existing state rather than a malformed request, so 409 fits better than 400. Hold the line that 409 describes a state problem the client could resolve by re-reading; validation errors that are wrong regardless of state go elsewhere.
What the spec says
RFC 9110 §15.5.10 describes 409 for a request that
conflicts with the resource’s current state, is intended for cases the user might resolve
and resubmit, and says the server SHOULD generate content with enough
information to recognise the source of the conflict. It calls out PUT with
versioning as the motivating example. The precondition machinery behind that is
§13.1: If-Match carrying an entity tag
from §8.8.3. Note the split: a failed If-Match is 412
Precondition Failed (§15.5.13), not 409. Use 412 when the client
supplied a validator that no longer matches, 409 when the application finds the conflict
with no precondition in play.
What actually causes it
The unique-constraint case is the most common and the easiest to get
right: catch the database error and translate it into 409 with a body naming the field,
instead of letting it become a 500. Concurrency is what teams get
wrong, usually by not implementing it. An API accepting an unconditional
PUT never returns 409 and loses updates quietly, surfacing weeks later as
“the system deleted my edits”. Adding
ETag on reads
and requiring If-Match on writes turns invisible data loss into a visible,
retryable error. State machines are the third family: cancelling a shipped order, or
paying a paid invoice.
How to debug it
Read current state before assuming your write is wrong. Fetch the resource and
compare its ETag against the validator you sent:
curl -sD - -o /dev/null https://api.example.com/v1/docs/42 | grep -i '^etag'
If they differ, somebody wrote in between: re-read, reapply your change, retry with the new tag. If they match and you still get 409, the conflict is application-level — a duplicate key or an invalid transition — and the body should say which.
For intermittent 409s under load, check whether retries are the cause rather than the
cure. A client retrying a non-idempotent POST after a timeout creates the
resource twice, and the second attempt conflicts with what the first made. Idempotency
keys fix that; blind retry loops do not, since without re-reading state every attempt
conflicts identically.
Working examples
curl
# Read the current validator.
curl -sD - -o /dev/null https://api.example.com/v1/docs/42 | grep -i '^etag'
# Conditional write: 409 or 412 instead of a silent lost update.
curl -i -X PUT \
-H 'If-Match: "v3"' \
-H 'Content-Type: application/json' \
-d '{"title":"new"}' \
https://api.example.com/v1/docs/42
# Duplicate create — expect 409 with a body naming the conflicting field.
curl -i -X POST -H 'Content-Type: application/json' \
-d '{"email":"taken@example.com"}' https://api.example.com/v1/users
Python (requests)
import requests
url = "https://api.example.com/v1/docs/42"
# Read-modify-write with optimistic concurrency.
cur = requests.get(url, timeout=10)
etag = cur.headers["ETag"]
doc = cur.json()
doc["title"] = "new title"
put = requests.put(url, json=doc, headers={"If-Match": etag}, timeout=10)
if put.status_code in (409, 412):
# Re-read and reapply; resending the same body will conflict forever.
print("conflict:", put.status_code, put.text[:200])
else:
put.raise_for_status()
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 put = await fetch(url, {
method: "PUT",
headers: { "If-Match": etag, "Content-Type": "application/json" },
body: JSON.stringify({ ...doc, title: "new title" }),
});
if (put.status === 409 || put.status === 412) {
// Someone else won the race. Re-read, merge, retry with the fresh ETag.
console.log("conflict", put.status, await put.text());
}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
- 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
- 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