Upserts and conflict handling
An upsert is one write that means “create this identity, or merge into the row that already owns it.” The unique constraint is the gate. Name that identity, decide whether a conflict is a no-op or a merge, then read the stored row so the result matches the rule you intended—not the race you hoped to win in application code.
A duplicate insert is a uniqueness conflict, not a retry
Last lesson, a second INSERT with the same primary key failed. That failure is useful. Two accounts must not share an email. The database is not being difficult; it is protecting the one-row meaning of the table.
Run the next script as written. The second insert should abort. The following SELECT never runs, because this runner stops on the first error. That is the same shape as a production statement that has no conflict clause: the whole command fails, and you must handle the error in the client.
Watch a duplicate email fail UNIQUE
Insert Ada, then insert the same email with a longer display name.
Edit the query, predict the rows it will return, then run it.
SELECT then INSERT is not a uniqueness check
A common application pattern is: look up the email, and insert only if the lookup is empty. In one tab, on toy data, it looks perfect. Under load, two requests can both see “missing,” both insert, and one of them still hits UNIQUE—or, if you skipped the constraint, you store two Adas.
The lookup below is not a lock. It is a snapshot of this isolated database at that moment. The unique constraint on email is the only check that still works when two writers arrive together. Put the uniqueness in the schema. Put the “what to do on conflict” in the insert. The next lesson covers transactions; they do not replace this rule.
See a lookup that proves nothing under concurrency
Select the email, then insert it. The pattern looks safe only because you are the only writer.
Edit the query, predict the rows it will return, then run it.
ON CONFLICT DO NOTHING is insert-if-absent
When a duplicate should be ignored—idempotent imports, “ensure this row exists,” webhook retries—use DO NOTHING. The first row for Ada stays. Grace is new, so she is inserted. No error. No second Ada.
Name the conflict target: ON CONFLICT(email) matches the unique column. If you omit the target, SQLite treats a conflict on any unique constraint as a no-op. Prefer the named form so a later unique index cannot silently swallow a different kind of duplicate.
Retry an import without duplicating Ada
Insert Ada, retry Ada with DO NOTHING, then add Grace the same way.
Edit the query, predict the rows it will return, then run it.
ON CONFLICT DO UPDATE is a merge
excluded is the row the insert would have created. DO UPDATE SET title = excluded.title copies those incoming values onto the existing row. Columns you do not mention keep their stored values. That is the difference between a merge and a rewrite.
The first statement creates NB-1. The second statement is a two-row upsert: revise the notebook price and title, and insert the sticker. One statement, two identities, two outcomes. Read every remaining row. The notebook should keep product_id 1. A merge is not a new product.
Revise a sku and insert another in one statement
Upsert a catalog by sku: update the notebook, add the sticker.
Edit the query, predict the rows it will return, then run it.
excluded is the incoming payload, not the stored counter
If the insert always sends login_count = 1, then SET login_count = excluded.login_count would freeze the counter at 1. The stored row owns the counter. Increment accounts.login_count. Use excluded for values that should come from this request, such as a new display name.
Write the merge as a sentence before you write SQL: “On a known email, take the latest name and add one login.” If you cannot say that sentence, you are not ready to choose SET expressions.
Merge a profile without resetting login_count
Upsert Ada's name and increment the stored login counter.
Edit the query, predict the rows it will return, then run it.
See REPLACE reset a column you did not list
Store twelve logins, then REPLACE with only id, email, and name.
Edit the query, predict the rows it will return, then run it.
A WHERE on DO UPDATE decides whether the new values win
Conflict found does not always mean overwrite. High scores should rise, never fall. Inventory imports should not apply a negative shipment. SQLite allows DO UPDATE SET ... WHERE .... If the WHERE is false, the existing row stays. That is still a successful statement: the conflict was handled, and the merge rule said “keep.”
Keep a high score from going backwards
Try to write 900 after 1200, then write 1500, using the same conflict rule.
Edit the query, predict the rows it will return, then run it.
The conflict target is the real identity, not a convenient column
Two suppliers can sell a product labeled NB-1. Uniqueness lives on (supplier_id, sku), not on sku alone. Name that pair in ON CONFLICT. Adding stock should add to the matching supplier's row, not to a different vendor's notebook.
If you conflict on the wrong columns, you will merge the wrong rows—or never merge, and then hit a different unique constraint with a less helpful error. Draw the identity first. The SQL only records that drawing.
Upsert stock on a composite catalog key
Two suppliers share the sku text NB-1. Merge only supplier 10's notebook and insert a sticker.
Edit the query, predict the rows it will return, then run it.
An upsert checklist
- Write the one-row meaning of the table, including which columns identify a row.
- Put that identity in a
PRIMARY KEYorUNIQUEconstraint. No constraint, no conflict. - Decide the conflict policy: ignore, merge specified columns, or fail (plain
INSERT). - For a merge, write a sentence: which incoming columns win, which stored columns stay, which counters increment.
- Name the conflict target. Prefer the columns that match the identity, not “any unique index.”
- Avoid
INSERT OR REPLACEunless deleting the old row is the product decision. - Run the upsert twice. The second run should be a stable merge or a no-op, not a second identity.
- SELECT the identity and nearby rows. Confirm preserved columns did not reset.
Independent lab: merge lesson progress without losing a best score
A learner can retry a quiz. The stored row should keep the best score, count every attempt, and become complete once any attempt is complete. A later worse score must not erase 10. A new lesson slug should insert. The starter already encodes that merge. Read it until you can predict all three rows, then run it. Afterward, change the second batch so the inserts lesson scores 3 instead of 9, and confirm best_score stays 9 while attempt_count still grows.
Upsert quiz attempts by learner and lesson
Merge retries onto a composite primary key without lowering a stored best score.
Edit the query, predict the rows it will return, then run it.
Common mistakes to avoid
- Checking existence in the application and treating that as a lock.
- Omitting
UNIQUE/ primary key, then wondering why “upsert” inserted twins. - Conflicting on a convenient column that is not the real identity.
- Setting counters to
excludedvalues that were only defaults on the insert. - Using
INSERT OR REPLACEand losing omitted columns or child rows. - Overwriting a high score, a paid flag, or an audit timestamp because the SET list was copied from a full row dump.
- Leaving the conflict unnamed so a new unique index changes which duplicates are swallowed.
- Skipping the verification SELECT after a “successful” upsert.
Lesson review
- I can explain why a unique constraint is the real uniqueness check.
- I can use
ON CONFLICT DO NOTHINGfor idempotent inserts. - I can merge with
DO UPDATEandexcludedwithout rewriting the whole row. - I can increment stored counters instead of replacing them from the payload.
- I can explain why
INSERT OR REPLACEis a delete-then-insert. - I can name a composite conflict target and gate a merge with
WHERE.
Check your conflict-handling reasoning
Answer all ten questions, then revisit the runner whose conflict target or merge rule still feels unclear.