Skip to content
Control Plane Labs

409 Conflict

Client error response defined by RFC 9110 §15.5.10.

Written and maintained by Ben Ennis

Last reviewed July 27, 2026 · How we verify this

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.

A concrete case: GitHub’s Contents API uses 409 instead of ETag

Not every API implements optimistic concurrency with ETag and If-Match; some bake the same idea directly into the resource representation. GitHub’s Create or Update File Contents endpoint requires the current blob sha in the request body when overwriting an existing file, and returns 409 when the supplied sha does not match what is currently stored, which is functionally identical to a failed If-Match but carried in the JSON payload rather than a conditional header. Two processes updating the same file without re-fetching the latest sha between writes will see the second one fail with 409 rather than silently overwriting the first, which is the whole point of optimistic concurrency, just implemented at the application layer instead of the HTTP layer.

The Git Database API returns 409 for a different reason entirely: an empty or not-yet-initialised repository cannot accept ref or tree operations because there is no commit for anything to be relative to, and GitHub reports that as a conflict with current state rather than a missing resource. This is a case worth remembering when a 409 shows up immediately after creating a repository through the API: the fix is to create an initial commit, not to retry the same call, since retrying an empty repository produces the identical 409 every time.

If you are building against a REST API that is not HTTP/1.1 textbook-pure, check its docs for a body-level equivalent of ETag before assuming 409 always means the generic If-Match story from the rest of this page.

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