Skip to content

Revision 1 — the current text. The evidence on this page is this revision’s own — it has not been carried forward from, or to, any other revision.

Handling D1 UNIQUE constraint violations

Covers genuine duplicate-key inserts and the check-then-insert race that produces the same error under concurrency. Does not cover foreign-key constraint failures (a different SQLITE_CONSTRAINT variant) or CHECK-constraint failures — those need a different diagnosis of the data itself, not of insert timing.

Unverifiedno reproductions yetWhy this confidence?
Revision 1published by DevYou curation

Symptoms

The diagnostic path

8 steps, exactly as this revision published them.

  1. Step 1 · StartINSERT throws UNIQUE constraint failed / SQLITE_CONSTRAINT

    A write to a table with a UNIQUE column or index (often the primary business key, like userid or email) fails with D1ERROR: UNIQUE constraint failed.

    What happens next

    • passed step 2, Confirm a row with that value already exists
  2. Step 2 · TestConfirm a row with that value already exists

    Query the table directly for the value that was rejected.

    Read-onlysql
    SELECT * FROM subscriptions WHERE user_id = ?;

    Expected result

    A row is returned, showing the value is already present.

    What happens next

    • passed step 3, Treat the duplicate as an update, or ignore it deliberately
    • failed step 4, Check whether this is a check-then-insert race
    • unknown step 3, Treat the duplicate as an update, or ignore it deliberately
  3. Step 3 · FixTreat the duplicate as an update, or ignore it deliberately

    If a duplicate for this key is a legitimate, expected case (e.g. re-running a signup flow), use SQLite's upsert syntax instead of a bare INSERT so the constraint stops being an error path at all.

    Changes statesql

    Changes system or service state. Review before running.

    Changes the insert into an upsert: a new user_id is inserted as before, but an existing user_id now has its plan overwritten instead of throwing. Review whether overwrite is the behaviour you actually want before shipping this.

    INSERT INTO subscriptions (user_id, plan) VALUES (?, ?)
    ON CONFLICT(user_id) DO UPDATE SET plan = excluded.plan;

    What happens next

    • passed step 5, Re-run the insert path against a duplicate value
  4. Step 4 · TestCheck whether this is a check-then-insert race

    If a SELECT to check for an existing row ran immediately before the INSERT and found nothing, but the INSERT still failed, another request for the same key completed its own insert in between the two calls. D1 gives you no cross-request lock to make a check-then-insert pattern safe from that race.

    Read-onlysh
    npx wrangler tail --format pretty

    Expected result

    Two near-simultaneous invocations for the same user_id, both passing their SELECT check, one losing the subsequent INSERT.

    What happens next

    • passed step 6, Stop pre-checking; let the constraint be the source of truth
    • failed step 7, Not a duplicate-row issue — escalate
    • unknown step 7, Not a duplicate-row issue — escalate
  5. Step 5 · Verify the fixRe-run the insert path against a duplicate value

    Send the same insert twice (or trigger the flow that previously raced) and confirm it no longer throws an uncaught error to the caller.

    Read-onlysh
    curl -s -X POST https://your-worker.example.workers.dev/subscribe -d '{"userId":"u_1"}' & curl -s -X POST https://your-worker.example.workers.dev/subscribe -d '{"userId":"u_1"}' & wait

    Expected result

    Both requests return a normal response; neither surfaces an unhandled SQLITE_CONSTRAINT error.

    What happens next

    • passed step 8, Resolved
    • failed step 7, Not a duplicate-row issue — escalate
    • unknown step 7, Not a duplicate-row issue — escalate
  6. Step 6 · FixStop pre-checking; let the constraint be the source of truth

    Remove the SELECT-then-INSERT pattern and attempt the INSERT directly, catching SQLITE_CONSTRAINT as your "already exists" signal. This is race-proof because SQLite enforces the constraint atomically at insert time, which a separate SELECT beforehand cannot be.

    Changes statets

    Changes system or service state. Review before running.

    Changes error handling only: a duplicate insert now resolves gracefully instead of throwing an uncaught error to the caller. It does not change what gets written for a genuinely new row.

    try {
      await env.DB.prepare("INSERT INTO subscriptions (user_id, plan) VALUES (?, ?)").bind(userId, plan).run();
    } catch (err) {
      if (String(err).includes("SQLITE_CONSTRAINT")) {
        // already exists — treat as success, or fetch and return the existing row
      } else {
        throw err;
      }
    }

    What happens next

    • passed step 5, Re-run the insert path against a duplicate value
  7. Step 7 · EndNot a duplicate-row issue — escalate

    If no existing row matches and this isn't a check-then-insert race, look at the schema for an unintentionally narrow UNIQUE index — for example a compound key that's more restrictive than the application logic assumes.

  8. Step 8 · EndResolved

    Duplicate inserts for the same key are now handled deliberately instead of throwing an unhandled error.

Sources

Why this confidence?

What would strengthen it: 6 more independent reproductions. Reproductions from 3 more distinct environments.

This counts only what was recorded against revision 1 itself. Nothing reported against another revision is included here — see the revision history for why.