409 Conflict (tested)
409 Conflict means your request is valid, but it clashes with the current state of the resource. Two common cases: you try to create something that already exists, or you update something that someone else changed first. The fix is usually to re-read the resource, then decide what to do.
1. Duplicate create: POST an email that already exists
Our API already had a user with ann@example.com. Creating it again returned 409 with an RFC 9457 problem body:
$ curl -si -X POST localhost:8983/users -H 'Content-Type: application/json' \
-d '{"email":"ann@example.com","name":"Ann2"}'
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{"type":"https://example.com/probs/duplicate-email","title":"Email already registered",
"status":409,"detail":"A user with email ann@example.com already exists.","instance":"/users"}
Use application/problem+json so clients can read type and detail in a standard way. Say what clashed. Do not leak other users' data in the message.
2. Optimistic concurrency: 412 with If-Match, 409 with a version field
A GET returns the current version as an ETag:
$ curl -si localhost:8983/users/1
HTTP/1.1 200 OK
ETag: "v1"
{"id":1,"email":"ann@example.com","name":"Ann","version":1}
An update with the right If-Match worked and the ETag moved to "v2". Sending the old If-Match: "v1" again gave 412:
$ curl -si -X PUT localhost:8983/users/1 -H 'If-Match: "v1"' \
-H 'Content-Type: application/json' -d '{"name":"Ann C"}'
HTTP/1.1 412 Precondition Failed
Content-Type: application/problem+json
{"type":"about:blank","title":"Precondition Failed","status":412,
"detail":"If-Match \"v1\" does not match current ETag \"v2\""}
The same stale update, but with the version inside the JSON body instead of a header, gave 409:
$ curl -si -X PUT localhost:8983/users/1 \
-H 'Content-Type: application/json' -d '{"name":"Ann C","version":1}'
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{"type":"https://example.com/probs/version-conflict","title":"Version conflict",
"status":409,"detail":"You sent version 1, current is 2.","currentVersion":2}
| How the client sends the version | Stale result | Why |
|---|---|---|
If-Match header (HTTP precondition) | 412 Precondition Failed | RFC 9110 says a failed precondition is 412. |
version field in the body (app logic) | 409 Conflict | The request is fine; it clashes with current state. |
Which to use? If you already send ETags, use If-Match and 412. It is standard, and caches and tools understand it. Use a body version and 409 when clients cannot set headers easily, or when the conflict is about business rules, not bytes. Pick one per API and document it.
3. A race: two updates with the same version
We read the current version, then fired two PUTs with that same version at the same time. We did this 5 times with parallel curl and once with Node Promise.all:
$ for i in 1 2; do curl -s -o /dev/null -w "%{http_code} " -X PUT localhost:8983/users/1 \
-H 'Content-Type: application/json' -d "{\"name\":\"R$i\",\"version\":$v}" & done; wait
200 409
# 5 of 5 curl runs: 200 409
# Node fetch with Promise.all: [ 200, 409 ]
Exactly one write won every time. The loser got 409 and can re-read. Be honest about why it worked here: our server is one Node process, and the check-and-write runs with no await in between, so it cannot interleave. With a database you need the check and the write in one step, for example UPDATE users SET ... , version = version + 1 WHERE id = ? AND version = ?, then return 409 if zero rows changed.
4. How clients see a 409
| Client | What we measured |
|---|---|
fetch() in Node 24 | Did not throw. res.ok was false, res.status 409, statusText "Conflict". |
curl (no flags) | Exit code 0. You must check the status yourself. |
curl --fail | Exit code 22. |
curl --retry 3 | No retries (num_retries = 0). curl does not treat 409 as transient. |
curl --retry 2 --retry-all-errors --fail | Retried 2 times, still 409, exit 22. |
const res = await fetch(url, { method: "POST", headers, body });
if (res.status === 409) {
const problem = await res.json(); // application/problem+json
// re-read the resource, merge or ask the user, then try again
}
Do not blindly retry a 409. The same request will clash again. Re-read first. This is different from 429 with Retry-After, where waiting does help.
5. 409 in well-known systems (from their docs, not measured)
| System | When it returns 409 |
|---|---|
| Kubernetes API | Create of an object that already exists (AlreadyExists), or an update with a stale resourceVersion ("the object has been modified"). |
| Elasticsearch | version_conflict_engine_exception when if_seq_no/if_primary_term do not match, or op_type=create on an existing id. |
| CouchDB | "Document update conflict" when the _rev you send is not the current one. |
| Amazon S3 | BucketAlreadyExists (name taken by another account) and BucketAlreadyOwnedByYou in most regions. |
| Git hosting APIs | GitHub's contents API returns 409 when the file sha you send is stale, and on some merge conflicts. |
6. 409 vs 400 vs 422 (and 415)
| Code | Use it when | Example |
|---|---|---|
| 400 Bad Request | The request is broken: bad JSON, missing fields. | {"email": |
| 415 Unsupported Media Type | Wrong body format. See 415. | Form data sent to a JSON-only API |
| 422 Unprocessable Content | Well-formed, but the values fail validation on their own. | Email has no @ |
| 409 Conflict | Valid on its own, but clashes with current state. | Email already taken; stale version |
| 412 Precondition Failed | An If-Match / If-Unmodified-Since header failed. | Stale ETag |
A simple test: would the same request succeed against an empty database? If yes, it is a 409, not a 400 or 422.
Frequently asked questions
What does 409 Conflict mean?
The request is valid, but it clashes with the current state of the resource. Typical causes are creating something that already exists or updating with a stale version.
Should I return 409 or 412 for a stale version?
If the client sent If-Match and it does not match the ETag, return 412. If the version is a field in the body checked by your app, return 409. In our test the same stale update gave 412 via If-Match and 409 via a body field.
Does fetch() throw on a 409?
No. In Node.js 24, fetch() resolved normally with res.ok false and res.status 409. You must check the status yourself.
Does curl --retry retry a 409?
No. With curl 8.14.1, --retry 3 made zero retries on 409. Only --retry-all-errors together with --fail made it retry, and it still got 409 each time.
Is a duplicate email a 409 or a 422?
Use 409. The email is valid on its own; it only fails because another user already has it. Use 422 when the value itself is invalid, like an email with no @.
Written with AI assistance. Measured on 2026-10-02 with curl 8.14.1 and Node.js 24.21.0: a small Node http API on 127.0.0.1:8983 that returns 409 for a duplicate email and for a stale version field, and 412 for a stale If-Match. We ran 6 parallel update races (5 with curl, 1 with fetch) and checked fetch(), curl --fail and curl --retry against a real 409. PlainHeaders is our extension; curl and Node.js are free.