HTTP 409 is Conflict: the request can’t be completed because it clashes with the current state of the resource. RFC 9110 section 15.5.10 meant it for conflicts the user “might be able to resolve”, and asks the server to send enough in the body for the user to see what clashed. Most 409s answer a PUT, PATCH or POST.
What sends it
- Creating something that already exists: a taken username, a duplicate slug. In PostgreSQL that’s error
23505,unique_violation. - Updating from a stale copy. The Kubernetes API answers 409 when an update’s
resourceVersionis out of date (“the object has been modified; please apply your changes to the latest version and try again”), and when you create a name that’s already taken. - Document stores: Elasticsearch and CouchDB answer 409 for version conflicts.
- State machines: cancelling an order that has shipped, deleting a folder that isn’t empty.
409 or its neighbours
body doesn't parse 400
parses, but a field breaks a rule 422 (or 400)
valid, but clashes with what's stored 409
If-Match sent, and the ETag has moved on 412 Precondition Failed
update had to be conditional, and wasn't 428 Precondition Required
For optimistic locking, HTTP has its own mechanism: the client sends back the ETag it read in If-Match, and the server answers 412 when it no longer matches (RFC 9110 section 13.1.1). 428 comes from RFC 6585 and tells a client it must send that condition. 409 fits when the version travels in the body, or when the clash isn’t about versions at all. Search 409 412 in the tool below to compare the two.
A body worth sending
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{"type": "https://example.com/probs/order-shipped", "title": "Order already shipped",
"status": 409, "detail": "Order 4182 shipped on 2026-10-12 and can't be cancelled", "state": "shipped"}
A client shouldn’t resend a 409 unchanged. It should fetch the current state, merge or ask the user, and try again; Kubernetes controllers do exactly that on a resourceVersion conflict, often through client-go’s retry.RetryOnConflict. 409 isn’t heuristically cacheable.
In your stack
try {
await db.query('INSERT INTO projects (slug) VALUES ($1)', [slug]);
} catch (err) {
if (err.code === '23505') return res.status(409).json({ error: 'slug_taken' }); // node-postgres
throw err;
}
try:
Project.objects.create(slug=slug)
except IntegrityError: # Django
return JsonResponse({"error": "slug_taken"}, status=409)
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" { // pgx
http.Error(w, "slug taken", http.StatusConflict)
return
}