Skip to content
Control Plane Labs

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/42

What 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