There's one operation real systems constantly need and that none of the three previous statements solves: "insert this row if it doesn't exist, and update it if it does".
Synchronising the catalogue with the file a supplier sends every week: some items are already in the system and only change price, others are new. Recording the stock received for a reference that may not even exist yet. Saving the rating of a review the customer may have written months ago. Registering a customer who may already have signed up. In all four cases, the answer to "INSERT or UPDATE?" is it depends on what's in the table, and you don't know until you look.
The solution everybody writes the first time —look and then decide— is wrong, and it's wrong in a way that never shows up in development and always shows up in production. This lesson starts by explaining why, and goes on to the two tools PostgreSQL offers to solve it in a single atomic statement: INSERT ... ON CONFLICT, its own solution since version 9.5, and MERGE, the SQL standard's, available since PostgreSQL 15.
Contents
- The problem: insert or update depending on what's there
- Why the naive solution is wrong
INSERT ... ON CONFLICT: the syntaxDO NOTHINGandDO UPDATE SET- The conflict target and why it requires a unique constraint
- The
EXCLUDEDpseudo-table and the accumulation pattern WHEREin theDO UPDATE: updating only if something changesRETURNINGwith an upsert, and how to tell an insert from an updateMERGE: the SQL standard's upsertON CONFLICTagainstMERGE- Support by engine
- Three GreenStore cases
- Common Mistakes and Tips
- Exercises
- Conclusion
- The problem: insert or update depending on what's there
Huerta del Turia (supplier 1) sends an updated catalogue file every Monday. This week it brings five references:
| name | price | cost | units sent |
|---|---|---|---|
| Extra virgin olive oil 500 ml | 12.90 | 7.95 | 60 |
| Organic brown rice 1 kg | 3.90 | 2.10 | 100 |
| Organic crushed tomato 400 g | 2.10 | 0.95 | 200 |
| Ginger kombucha 750 ml | 5.25 | 2.45 | 40 |
| Organic chickpeas 500 g | 2.60 | 1.15 | 140 |
Four are already in the catalogue (with different prices); the fifth is new. And not even all the existing ones change: the rice is still at €3.90 and €2.10.
With what you know so far, you'd have to split the file into two groups by hand and write an INSERT for one and an UPDATE for the other. It's tedious, and with five hundred references it's downright unworkable. What you need is a statement that decides for itself, row by row.
That's an UPSERT: update + insert.
- Why the naive solution is wrong
Everybody's first idea, and the one most applications write:
-- ⚠️ INCORRECT as soon as there's more than one user
SELECT COUNT(*) FROM products WHERE name = 'Organic chickpeas 500 g';
-- if it returns 0 → INSERT
-- if it returns 1 → UPDATEIt works perfectly… as long as you're the only person connected. As soon as there are two processes working at once, a race condition appears:
sequenceDiagram
participant A as Session A
participant DB as Database
participant B as Session B
A->>DB: SELECT ... WHERE name = 'Chickpeas'
DB-->>A: 0 rows → "doesn't exist, I'll insert"
B->>DB: SELECT ... WHERE name = 'Chickpeas'
DB-->>B: 0 rows → "doesn't exist, I'll insert"
A->>DB: INSERT ... 'Chickpeas'
DB-->>A: INSERT 0 1 ✅
B->>DB: INSERT ... 'Chickpeas'
DB-->>B: 💥 duplicate key value violates<br/>unique constraint
Both sessions look, both see it doesn't exist, both decide to insert. The first succeeds; the second blows up. And there's a worse variant: if there were no unique constraint, the second insertion would succeed and you'd end up with two duplicate rows and no error at all.
The interval between the SELECT and the INSERT may look tiny —milliseconds—, but on a system processing thousands of operations a minute that window gets crossed constantly. It isn't a rare case: it's a guaranteed one. The general rule behind it:
Checking and then acting in two separate statements is never safe against concurrency. Between the check and the action, the world may have changed.
The three ways of solving it, from worst to best:
| Approach | Problem |
|---|---|
SELECT and then INSERT/UPDATE |
Race condition. Incorrect |
Try the INSERT and catch the duplicate error in the application |
It works, but it turns a normal case into an exception, it clutters the code and on some engines it invalidates the whole transaction |
A single atomic statement: ON CONFLICT or MERGE |
The engine resolves the conflict internally, with the right locks. Correct |
Why the third option is safe. When PostgreSQL runs an
INSERT ... ON CONFLICT, the check and the write happen inside the same operation, with the appropriate row and index lock: there's no window between "look" and "act". The complete mechanism —what gets locked, for how long and what the other sessions see— is module 9. Here it's enough to know that one statement is atomic and two aren't.
INSERT ... ON CONFLICT: the syntax
INSERT ... ON CONFLICT: the syntaxINSERT INTO table (columns)
VALUES (...)
ON CONFLICT (column_or_columns) -- or: ON CONSTRAINT constraint_name
DO NOTHING;
-- or else:
INSERT INTO table (columns)
VALUES (...)
ON CONFLICT (column_or_columns)
DO UPDATE SET column = value [, ...]
[WHERE condition];It reads literally: "insert this; if it clashes with the uniqueness of these columns, do nothing / or do this UPDATE instead".
Let's start with the case that needs nothing new, because GreenStore already has the constraint: customers.email is UNIQUE.
DO NOTHING and DO UPDATE SET
DO NOTHING and DO UPDATE SET(The three examples in this section are run in order on the freshly reloaded database. Watch the ids: why they come out the way they do is explained at the end.)
DO NOTHING: ignore the conflict
INSERT INTO customers (name, last_name, email, city, country, signup_date)
VALUES ('Lucía', 'Martínez Soler', 'lucia.martinez@example.com', 'Alicante', 'Spain', DATE '2026-03-01')
ON CONFLICT (email) DO NOTHING;Zero rows inserted, zero errors. The customer already existed with that email, so the statement simply did nothing.
| id | name | last_name | city | signup_date | |
|---|---|---|---|---|---|
| 1 | Lucía | Martínez Soler | lucia.martinez@example.com | Valencia | 2025-01-10 |
Untouched: still in Valencia and with her original signup date.
DO NOTHING is ideal for rerunnable master data: development seeds, reference catalogues, initial loads. It turns a fragile script into an idempotent one (05-03).
DO UPDATE SET: the real upsert
INSERT INTO customers (name, last_name, email, city, country, signup_date)
VALUES ('Lucía', 'Martínez Soler', 'lucia.martinez@example.com', 'Alicante', 'Spain', DATE '2026-03-01')
ON CONFLICT (email) DO UPDATE
SET city = EXCLUDED.city
RETURNING id, name, last_name, email, city, signup_date;| id | name | last_name | city | signup_date | |
|---|---|---|---|---|---|
| 1 | Lucía | Martínez Soler | lucia.martinez@example.com | Alicante | 2025-01-10 |
The city has gone from Valencia to Alicante, and signup_date has not changed: only what appears in the SET gets updated. That's exactly what we want — a customer's original signup date shouldn't be rewritten just because their details get sent to us again.
And with a new email, the same statement inserts:
INSERT INTO customers (name, last_name, email, city, country, signup_date)
VALUES ('Aitor', 'Zubizarreta Egaña', 'aitor.zubi@example.com', 'Bilbao', 'Spain', DATE '2026-03-01')
ON CONFLICT (email) DO UPDATE
SET city = EXCLUDED.city
RETURNING id, name, last_name, email, city, signup_date;| id | name | last_name | city | signup_date | |
|---|---|---|---|---|---|
| 18 | Aitor | Zubizarreta Egaña | aitor.zubi@example.com | Bilbao | 2026-03-01 |
The same statement has inserted in one case and updated in the other, without you having had to decide anything. That's the upsert.
Why the
idis 18 and not 16. GreenStore has 15 customers and its sequence was left at 15 after the script'ssetval. But an upsert that ends in a conflict also consumes a sequence value: PostgreSQL builds the complete row —evaluating everyDEFAULT,nextvalincluded— before checking the unique index. The two previous examples on Lucía took 16 and 17, and Aitor got 18.It's consistent with what you saw in 05-02: sequences aren't undone. Practical consequence: in a table with frequent upserts, the
ids have big gaps and they don't count rows. If that matters to you, review the type: anINTEGERruns out at 2,147,483,647, and with massive upserts that comes sooner than you'd think.BIGINTis the usual answer.
- The conflict target and why it requires a unique constraint
The ON CONFLICT (...) isn't decorative: it tells PostgreSQL which conflict to intercept. And it can only intercept violations of a UNIQUE, PRIMARY KEY or unique index constraint.
There are two ways to state it:
ON CONFLICT (email) -- by column (inference)
ON CONFLICT ON CONSTRAINT customers_email_key -- by constraint name| Form | Advantage | Drawback |
|---|---|---|
| By column | It doesn't depend on the constraint's name; it survives a RENAME CONSTRAINT |
Ambiguous if there are two constraints over the same columns |
| By name | Completely explicit | It breaks if somebody renames the constraint (another argument for naming them well, 05-01) |
If you name columns that aren't covered by any unique constraint, it fails:
-- ⚠️ INCORRECT: products.name isn't UNIQUE in GreenStore
INSERT INTO products (name, category_id, supplier_id, price, cost, stock)
VALUES ('Extra virgin olive oil 500 ml', 1, 1, 12.90, 7.95, 60)
ON CONFLICT (name) DO UPDATE SET price = EXCLUDED.price;And this is a design limitation, not a whim. For PostgreSQL to be able to decide atomically that "it already exists", it needs a unique index to tell it so without scanning the table; without one it would have to search, and we'd be back to section 2's race window.
So to synchronise the catalogue by name you have to add the constraint:
-- A one-off example for this lesson: it is NOT part of GreenStore's
-- canonical schema. Add it to practise and remove it afterwards.
ALTER TABLE products
ADD CONSTRAINT uq_products_name UNIQUE (name);It works because the catalogue's twenty names are all different. And along the way it illustrates something you'll see in depth in 05-06: adding a UNIQUE creates an index underneath, and that index is what makes the upsert possible (the structures and their cost, in module 8).
Now we're ready:
INSERT INTO products (name, category_id, supplier_id, price, cost, stock)
VALUES ('Extra virgin olive oil 500 ml', 1, 1, 12.90, 7.95, 60)
ON CONFLICT (name) DO UPDATE
SET price = EXCLUDED.price,
cost = EXCLUDED.cost
RETURNING id, name, price, cost, stock;| id | name | price | cost | stock |
|---|---|---|---|---|
| 1 | Extra virgin olive oil 500 ml | 12.90 | 7.95 | 120 |
Price and cost updated; the stock is still 120 because it isn't in the SET. The file's 60 units have been ignored, which in this case is correct: they're units sent, not the total stock.
- The
EXCLUDED pseudo-table and the accumulation pattern
EXCLUDED pseudo-table and the accumulation patternEXCLUDED is the key to the whole mechanism. It's a pseudo-table containing the row that was proposed for insertion and was rejected because of the conflict.
Inside the DO UPDATE SET two worlds coexist:
| Reference | What it points at |
|---|---|
EXCLUDED.column |
The proposed value (the one that came in the VALUES or the SELECT) |
products.column or table.column |
The current value of the row already in the table |
With the two of them you can write any merge rule:
-- Keep the new value
SET price = EXCLUDED.price
-- Keep the current one (equivalent to leaving it out)
SET price = products.price
-- Keep the larger of the two
SET stock = GREATEST(products.stock, EXCLUDED.stock)
-- ACCUMULATE: add the new one to the existing one
SET stock = products.stock + EXCLUDED.stockThat last one is the accumulation pattern, and it's probably the most valuable use of the upsert. Recording a goods receipt is exactly that: "if the product is already in the system, add the units to it; if not, create it with those units".
| id | name | stock |
|---|---|---|
| 1 | Extra virgin olive oil 500 ml | 120 |
| 2 | Organic brown rice 1 kg | 200 |
| 5 | Organic crushed tomato 400 g | 300 |
| 16 | Ginger kombucha 750 ml | 60 |
INSERT INTO products (name, category_id, supplier_id, price, cost, stock) VALUES
('Extra virgin olive oil 500 ml', 1, 1, 12.90, 7.95, 60),
('Organic brown rice 1 kg', 1, 1, 3.90, 2.10, 100),
('Organic crushed tomato 400 g', 1, 1, 2.10, 0.95, 200),
('Ginger kombucha 750 ml', 4, 1, 5.25, 2.45, 40),
('Organic chickpeas 500 g', 1, 1, 2.60, 1.15, 140)
ON CONFLICT (name) DO UPDATE
SET stock = products.stock + EXCLUDED.stock
RETURNING id, name, price, stock;| id | name | price | stock |
|---|---|---|---|
| 1 | Extra virgin olive oil 500 ml | 12.50 | 180 |
| 2 | Organic brown rice 1 kg | 3.90 | 300 |
| 5 | Organic crushed tomato 400 g | 1.95 | 500 |
| 16 | Ginger kombucha 750 ml | 4.95 | 100 |
| 21 | Organic chickpeas 500 g | 2.60 | 140 |
Five rows: four accumulated and one created. 120+60=180, 200+100=300, 300+200=500, 60+40=100, and the chickpeas come in with their 140 units and the id 21.
Notice one revealing detail: the prices of the four updated rows are still the old ones (€12.50, €3.90, €1.95, €4.95), because the SET only touches stock. Only the new row got the file's price. The upsert lets you control field by field what gets merged and what gets kept.
And the compulsory warning, brother of UPDATE ... FROM's (05-03):
⚠️
EXCLUDEDdoesn't accumulate across tuples of the same statement. If the file brought the same name twice, PostgreSQL would fail withON CONFLICT DO UPDATE command cannot affect row a second time. It doesn't add the two together: it refuses. Deduplicate the source before you upsert.
ERROR: ON CONFLICT DO UPDATE command cannot affect row a second time HINT: Ensure that no rows proposed for insertion within the same command have duplicate constrained values.
That error is actually good news: the engine refuses to do something ambiguous rather than inventing a result. Compare it with UPDATE ... FROM, which in the same situation picks a match at random without warning you.
WHERE in the DO UPDATE: updating only if something changes
WHERE in the DO UPDATE: updating only if something changesThe DO UPDATE accepts a WHERE of its own, which decides whether the update is applied or discarded:
INSERT INTO products (name, category_id, supplier_id, price, cost, stock) VALUES
('Extra virgin olive oil 500 ml', 1, 1, 12.90, 7.95, 60),
('Organic brown rice 1 kg', 1, 1, 3.90, 2.10, 100),
('Organic crushed tomato 400 g', 1, 1, 2.10, 0.95, 200),
('Ginger kombucha 750 ml', 4, 1, 5.25, 2.45, 40),
('Organic chickpeas 500 g', 1, 1, 2.60, 1.15, 140)
ON CONFLICT (name) DO UPDATE
SET price = EXCLUDED.price,
cost = EXCLUDED.cost
WHERE products.price IS DISTINCT FROM EXCLUDED.price
OR products.cost IS DISTINCT FROM EXCLUDED.cost
RETURNING id, name, price, cost;| id | name | price | cost |
|---|---|---|---|
| 1 | Extra virgin olive oil 500 ml | 12.90 | 7.95 |
| 5 | Organic crushed tomato 400 g | 2.10 | 0.95 |
| 16 | Ginger kombucha 750 ml | 5.25 | 2.45 |
| 21 | Organic chickpeas 500 g | 2.60 | 1.15 |
Four rows instead of five. The brown rice came in at €3.90 and €2.10, exactly what it already had, so the WHERE discarded its update and it doesn't even appear in the RETURNING.
Why that extra line is worth it:
| Reason | Detail |
|---|---|
| Fewer writes | Every UPDATE creates a new version of the row even if the value is identical. With thousands of rows and 5 % real changes, the saving is enormous |
| Fewer locks | A row that isn't updated isn't locked for the other sessions (module 9) |
| Honest auditing | If there are audit triggers (module 10), they don't record changes that never happened |
The RETURNING tells the truth |
It gives you back only what actually changed |
Notice the use of IS DISTINCT FROM instead of <>. It's 04-03 all over again: if products.cost were NULL, the comparison products.cost <> EXCLUDED.cost would give UNKNOWN, the WHERE would discard it and the cost would never be updated. IS DISTINCT FROM treats two nulls as equal and a null against a value as different, which is what we need here. It's one of the places where that lesson pays its debt.
RETURNING with an upsert, and how to tell an insert from an update
RETURNING with an upsert, and how to tell an insert from an updateRETURNING works just as it does in INSERT and UPDATE: it returns the affected rows, whether inserted or updated. What it doesn't tell you directly is which was which.
There's a very widespread trick based on the system column xmax (the example runs on the freshly reloaded database):
INSERT INTO customers (name, last_name, email, city, country, signup_date) VALUES
('Lucía', 'Martínez Soler', 'lucia.martinez@example.com', 'Alicante', 'Spain', DATE '2026-03-01'),
('Marisol', 'Aguirre Peña', 'marisol.aguirre@example.com', 'Málaga', 'Spain', DATE '2026-03-01')
ON CONFLICT (email) DO UPDATE
SET city = EXCLUDED.city
RETURNING id,
name,
email,
city,
(xmax = 0) AS was_insert;| id | name | city | was_insert | |
|---|---|---|---|---|
| 1 | Lucía | lucia.martinez@example.com | Alicante | false |
| 17 | Marisol | marisol.aguirre@example.com | Málaga | true |
Lucía was updated (false), Marisol was created (true) — with the id 17, because the failed attempt on Lucía took 16.
How it works. xmax is a hidden column PostgreSQL uses internally for version control: it holds the identifier of the transaction that deleted or locked the row. On a freshly inserted row it's 0; on one updated inside the upsert, it isn't.
⚠️ Use it with reservations.
xmaxis an implementation detail, not a documented interface. It can change between versions, and there are situations (rows locked by other transactions, a previousSELECT ... FOR UPDATE) in which a non-zeroxmaxdoesn't mean what you think. It's perfectly good for debugging and for an internal report; don't build critical business logic on it.
And one limitation that does matter: with DO NOTHING, the conflicting rows don't appear in the RETURNING at all. You'll only see the ones that were genuinely inserted:
INSERT INTO categories (name, description) VALUES
('Drinks', 'Already exists'),
('Bulk pantry', 'Pulses, grains and nuts with no packaging')
ON CONFLICT (name) DO NOTHING
RETURNING id, name;| id | name |
|---|---|
| 7 | Bulk pantry |
One of the two. If you also need to know what got ignored, DO NOTHING isn't going to tell you.
MERGE: the SQL standard's upsert
MERGE: the SQL standard's upsertON CONFLICT is a PostgreSQL extension. The SQL:2003 standard defines the MERGE statement for the same job, which PostgreSQL adopted in version 15.
MERGE INTO target_table AS t
USING source AS s
ON t.key = s.key
WHEN MATCHED [AND condition] THEN
UPDATE SET column = value [, ...]
WHEN MATCHED [AND condition] THEN
DELETE
WHEN NOT MATCHED [AND condition] THEN
INSERT (columns) VALUES (values)
[WHEN NOT MATCHED THEN DO NOTHING];
The logic is a JOIN's: target and source are matched by the ON condition, and for each row the first WHEN clause that holds is executed.
The source can be a table, a query or a list of values. For GreenStore's case, the natural choice is a staging table with the contents of the supplier's file:
-- An auxiliary table for this lesson: it is NOT part of the canonical schema
CREATE TEMP TABLE supplier_catalog (
name VARCHAR(150) NOT NULL PRIMARY KEY,
category_id INTEGER NOT NULL,
price NUMERIC(10,2) NOT NULL CHECK (price >= 0),
cost NUMERIC(10,2) NOT NULL CHECK (cost >= 0),
units INTEGER NOT NULL CHECK (units > 0)
);
INSERT INTO supplier_catalog (name, category_id, price, cost, units) VALUES
('Extra virgin olive oil 500 ml', 1, 12.90, 7.95, 60),
('Organic brown rice 1 kg', 1, 3.90, 2.10, 100),
('Organic crushed tomato 400 g', 1, 2.10, 0.95, 200),
('Ginger kombucha 750 ml', 4, 5.25, 2.45, 40),
('Organic chickpeas 500 g', 1, 2.60, 1.15, 140);That COPY into a staging table followed by a merge towards the definitive table is the professional data integration pattern 05-02 announced.
And now the same case as section 7, solved with MERGE:
MERGE INTO products AS p
USING supplier_catalog AS c
ON p.name = c.name
WHEN MATCHED AND (p.price IS DISTINCT FROM c.price
OR p.cost IS DISTINCT FROM c.cost) THEN
UPDATE SET price = c.price,
cost = c.cost
WHEN NOT MATCHED THEN
INSERT (name, category_id, supplier_id, price, cost, stock)
VALUES (c.name, c.category_id, 1, c.price, c.cost, c.units);Four rows affected: three updates (olive oil, tomato and kombucha) and one insertion (chickpeas). The rice doesn't satisfy the WHEN MATCHED condition and, with no further applicable clauses, it stays as it is.
| id | name | price | cost | stock |
|---|---|---|---|---|
| 1 | Extra virgin olive oil 500 ml | 12.90 | 7.95 | 120 |
| 2 | Organic brown rice 1 kg | 3.90 | 2.10 | 200 |
| 5 | Organic crushed tomato 400 g | 2.10 | 0.95 | 300 |
| 16 | Ginger kombucha 750 ml | 5.25 | 2.45 | 60 |
| 17 | Cold-pressed orange juice 1 L | 5.40 | 2.60 | 90 |
| 21 | Organic chickpeas 500 g | 2.60 | 1.15 | 140 |
Notice the orange juice (17): it's from Huerta del Turia but it didn't come in the file, and it has been left untouched. Neither ON CONFLICT nor this MERGE does anything with target rows the source doesn't mention. If the business criterion were "anything not in the file gets discontinued", you'd need two operations… or a WHEN NOT MATCHED BY SOURCE clause, which PostgreSQL 16 still doesn't have (it arrived in 17).
What MERGE can do and ON CONFLICT can't
Several conditions and DELETE. This MERGE synchronises the catalogue and also discontinues whatever the supplier sends at price 0:
MERGE INTO products AS p
USING supplier_catalog AS c
ON p.name = c.name
WHEN MATCHED AND c.price = 0 THEN
UPDATE SET active = FALSE, stock = 0
WHEN MATCHED AND (p.price IS DISTINCT FROM c.price) THEN
UPDATE SET price = c.price, cost = c.cost
WHEN NOT MATCHED AND c.price > 0 THEN
INSERT (name, category_id, supplier_id, price, cost, stock)
VALUES (c.name, c.category_id, 1, c.price, c.cost, c.units);Three different rules in one statement, evaluated in order: the first one that holds wins. With ON CONFLICT this can't be expressed; you'd need several statements.
MERGE's limitations in PostgreSQL 16
| Limitation | Detail |
|---|---|
No RETURNING |
MERGE ... RETURNING arrived in PostgreSQL 17. In 16 you only get the MERGE N counter |
No WHEN NOT MATCHED BY SOURCE |
Also from 17 |
| It isn't immune to concurrency | This is the important one: under concurrent load, a MERGE can fail with duplicate key value if another session inserts the same key between the matching and the write. ON CONFLICT does guarantee that won't happen |
| It doesn't group the source | If supplier_catalog brought two rows with the same name, the MERGE fails with MERGE command cannot affect row a second time, just like the upsert |
That third limitation is counter-intuitive and worth remembering: the standard statement is less robust against concurrency than the proprietary extension, because ON CONFLICT leans directly on the unique index and MERGE doesn't require one.
ON CONFLICT against MERGE
ON CONFLICT against MERGEINSERT ... ON CONFLICT |
MERGE |
|
|---|---|---|
| Standard SQL | No: a PostgreSQL extension | Yes (SQL:2003) |
| Available since | PostgreSQL 9.5 | PostgreSQL 15 |
| Requires a unique constraint | Yes, compulsorily | No: the ON condition is enough |
| Atomic against concurrency | Yes, guaranteed | Not always: it can fail with a duplicate key |
| Possible actions | DO NOTHING, DO UPDATE |
UPDATE, INSERT, DELETE, DO NOTHING |
| Several conditions | Just one (DO UPDATE ... WHERE) |
Several WHEN clauses, evaluated in order |
| Source | VALUES or SELECT |
A table, a query or VALUES |
RETURNING |
Yes | Not in PostgreSQL 16 (yes in 17) |
| Access to the existing row and the proposed one | table.col and EXCLUDED.col |
target.col and source.col |
| Readability with complex rules | It gets messy | Better |
When to use each:
ON CONFLICTwhen the case is the classic "insert or update" over a unique key, when there's real concurrency, or when you needRETURNING. That's 90 % of cases.MERGEwhen you need several rules (update some, delete others, insert the rest), when the matching isn't on a unique key, when the source is a complex query, or when the code has to be portable to Oracle or SQL Server.
- Support by engine
| Engine | Syntax | Notes |
|---|---|---|
| PostgreSQL 15+ | INSERT ... ON CONFLICT and MERGE |
Both. ON CONFLICT is the recommended one for the simple case |
| MySQL / MariaDB | INSERT ... ON DUPLICATE KEY UPDATE |
You don't state the constraint: it fires on any unique key violated. To read the proposed row: AS new (MySQL 8.0.19+) or the old VALUES(col) function. MySQL 8 does not have MERGE |
| SQLite 3.24+ | INSERT ... ON CONFLICT |
Copied from PostgreSQL, with excluded in lower case. No MERGE |
| SQL Server | MERGE |
Since 2008. Historically with several documented concurrency bugs; many teams prefer UPDATE + INSERT inside a transaction |
| Oracle | MERGE |
Since 9i, very mature and massively used. It has no ON CONFLICT |
The special case of SQLite's INSERT OR REPLACE
SQLite also offers INSERT OR REPLACE INTO ..., which a lot of people use believing it's an upsert. It isn't, and the difference is serious:
ON CONFLICT DO UPDATE |
INSERT OR REPLACE |
|
|---|---|---|
| What it does to the existing row | It modifies it | It deletes it and creates a new one |
| Columns not mentioned | They keep their value | They're lost: they take their DEFAULT or NULL |
Primary key (rowid) |
Preserved | It changes |
The children's ON DELETE CASCADE |
Doesn't fire | It fires: the child rows are deleted |
| Delete triggers | No | Yes |
Applied to GreenStore it would be a silent catastrophe: an INSERT OR REPLACE on products would delete the row, and with it all its reviews would go in cascade (reviews.product_id is ON DELETE CASCADE). The "upsert" would have destroyed data in another table without ever mentioning it.
The rule: in SQLite use
ON CONFLICT ... DO UPDATE, neverINSERT OR REPLACE, unless delete-and-recreate is exactly what you want.
- Three GreenStore cases
12.1. Synchronising the catalogue with the supplier's file
Solved in sections 7 and 9, both ways. It requires the uq_products_name constraint for the ON CONFLICT version; the MERGE would work just the same without it.
12.2. Recording or incrementing the stock received
Solved in section 6 with the accumulation pattern SET stock = products.stock + EXCLUDED.stock. It's the case where the upsert shines: a single statement processes an entire delivery note, whether it creates new references or adds to existing ones.
A more complete version, which also updates the purchase cost and records the added date only on the new references:
INSERT INTO products (name, category_id, supplier_id, price, cost, stock, added_date)
SELECT c.name, c.category_id, 1, c.price, c.cost, c.units, DATE '2026-03-02'
FROM supplier_catalog AS c
ON CONFLICT (name) DO UPDATE
SET stock = products.stock + EXCLUDED.stock,
cost = EXCLUDED.cost
RETURNING id, name, price, cost, stock, added_date, (xmax = 0) AS is_new;| id | name | price | cost | stock | added_date | is_new |
|---|---|---|---|---|---|---|
| 1 | Extra virgin olive oil 500 ml | 12.50 | 7.95 | 180 | 2025-01-15 | false |
| 2 | Organic brown rice 1 kg | 3.90 | 2.10 | 300 | 2025-01-15 | false |
| 5 | Organic crushed tomato 400 g | 1.95 | 0.95 | 500 | 2025-01-20 | false |
| 16 | Ginger kombucha 750 ml | 4.95 | 2.45 | 100 | 2025-02-20 | false |
| 21 | Organic chickpeas 500 g | 2.60 | 1.15 | 140 | 2026-03-02 | true |
Four accumulations and one new entry. And look at the added_date column: the four existing ones keep theirs (2025) and only the new one gets today's, because added_date doesn't appear in the SET. That asymmetry —some fields get merged and others only get filled in on creation— is exactly what makes DO UPDATE useful compared with a REPLACE.
Notice as well that the source is a SELECT, not a VALUES. INSERT ... SELECT ... ON CONFLICT combines 05-02's material with this lesson's, and it's the usual way of processing an entire staging table.
12.3. Updating the rating of an existing review
A customer rates a product they'd already reviewed. The business rule: one review per customer and product, and the latest one replaces the previous.
Today reviews doesn't prevent duplicates, so first that rule has to be declared:
-- A one-off example for this lesson: it is NOT part of GreenStore's
-- canonical schema.
ALTER TABLE reviews
ADD CONSTRAINT uq_reviews_product_customer UNIQUE (product_id, customer_id);It works because the twelve current reviews all have different (product_id, customer_id) pairs. If there were duplicates, the ALTER TABLE would fail — which is exactly what it should do.
Now the upsert. Customer 6 (Pau Llorens Vidal) had given the Kombucha a 2; they've changed the recipe and he wants to raise it to 4:
SELECT id, product_id, customer_id, rating, comment, date
FROM reviews WHERE product_id = 16 AND customer_id = 6;| id | product_id | customer_id | rating | comment | date |
|---|---|---|---|---|---|
| 6 | 16 | 6 | 2 | Far too much ginger for my taste, barely drinkable. | 2025-06-20 |
INSERT INTO reviews (product_id, customer_id, rating, comment, date)
VALUES (16, 6, 4, 'They have toned down the ginger and now it is beautifully balanced.', DATE '2026-03-02')
ON CONFLICT (product_id, customer_id) DO UPDATE
SET rating = EXCLUDED.rating,
comment = EXCLUDED.comment,
date = EXCLUDED.date
RETURNING id, product_id, customer_id, rating, comment, date, (xmax = 0) AS is_new;| id | product_id | customer_id | rating | comment | date | is_new |
|---|---|---|---|---|---|---|
| 6 | 16 | 6 | 4 | They have toned down the ginger and now it is beautifully balanced. | 2026-03-02 | false |
The same row (id 6), new content. And with a customer who has never reviewed that product, the same statement creates the review:
INSERT INTO reviews (product_id, customer_id, rating, comment, date)
VALUES (16, 1, 5, 'The best kombucha I have ever tried.', DATE '2026-03-02')
ON CONFLICT (product_id, customer_id) DO UPDATE
SET rating = EXCLUDED.rating,
comment = EXCLUDED.comment,
date = EXCLUDED.date
RETURNING id, product_id, customer_id, rating, date, (xmax = 0) AS is_new;| id | product_id | customer_id | rating | date | is_new |
|---|---|---|---|---|---|
| 14 | 16 | 1 | 5 | 2026-03-02 | true |
The id is 14 and not 13 for the same reason as in section 4: the previous upsert, which ended in a DO UPDATE, had already taken 13 from the sequence.
And the effect on the product's rating:
SELECT p.id,
p.name,
COUNT(r.id) AS reviews,
ROUND(AVG(r.rating), 2) AS avg_rating
FROM products AS p
LEFT JOIN reviews AS r ON r.product_id = p.id
WHERE p.id = 16
GROUP BY p.id, p.name;| id | name | reviews | avg_rating |
|---|---|---|---|
| 16 | Ginger kombucha 750 ml | 2 | 4.50 |
From a lonely 2 to an average of 4.50 with two reviews. The Kombucha stops being the catalogue's worst product.
Remember to undo the examples. This lesson's two constraints (
uq_products_nameanduq_reviews_product_customer) are not part of the course schema. Remove them withALTER TABLE ... DROP CONSTRAINT ...or, more simply, reloadgreenstore.sql. The full syntax ofALTER TABLEis the next lesson.
Common Mistakes and Tips
- Solving the upsert with
SELECT+INSERT/UPDATE. A guaranteed race condition under concurrency. One statement is atomic; two aren't. - Using
ON CONFLICTover columns with no unique constraint.there is no unique or exclusion constraint matching the ON CONFLICT specification. The unique index is the mechanism, not a bureaucratic requirement. - Forgetting the
EXCLUDED.prefix in theDO UPDATE.SET price = priceis a circular assignment: the row stays as it was and no error is raised. - Bringing the same key value twice in the same statement.
cannot affect row a second time. Deduplicate the source first. - Comparing with
<>instead ofIS DISTINCT FROMin theDO UPDATE'sWHERE. With aNULLinvolved the comparison givesUNKNOWNand the row is never updated (04-03). - Expecting
DO NOTHINGto tell you what was ignored. It doesn't appear in theRETURNING. If you need to know, useDO UPDATEwith aWHERE. - Building business logic on
xmax = 0. It's an implementation detail. Good for debugging; not for invoicing. - Believing the upsert also updates what the source doesn't mention. It doesn't. Target rows absent from the file are left untouched.
- Using
INSERT OR REPLACEin SQLite believing it's an upsert. It deletes and recreates: it loses columns, it changes therowidand it fires the delete cascades. - Assuming
MERGEis safe against concurrency. It isn't in PostgreSQL 16: it can fail with a duplicate key.ON CONFLICTis. - Tip: for a delivery note or a file, load into a staging table and merge from there.
COPY+INSERT ... SELECT ... ON CONFLICT(orMERGE) is the professional integration pattern. - Tip: decide field by field what gets merged.
priceyes,added_dateno,stockaccumulating. That granularity is the entire value ofDO UPDATE. - Tip: always put the
WHERE ... IS DISTINCT FROMin theDO UPDATE. Fewer writes, fewer locks, honest auditing and aRETURNINGthat tells the truth.
Exercises
Work on the freshly reloaded database, inside BEGIN … ROLLBACK.
Exercise 1
GreenStore receives a file of customer additions and updates from a marketing campaign:
| name | last_name | city | country | |
|---|---|---|---|---|
| Lucía | Martínez Soler | lucia.martinez@example.com | Gandía | Spain |
| Sofia | Moreira Costa | sofia.moreira@example.pt | Coimbra | Portugal |
| Aitor | Zubizarreta Egaña | aitor.zubi@example.com | Bilbao | Spain |
| Nadia | Benali Torres | nadia.benali@example.com | Valencia | Spain |
Write a single statement that adds the ones that don't exist and updates the city of the ones that do, with these rules:
signup_datemust be2026-03-02for the new ones and must not be modified on the existing ones.- The city should only be updated if it genuinely changes.
- The result must indicate, per row, whether it was an addition or an update.
Then answer: how many rows does the RETURNING give back and why?
Exercise 2
Solve the same case as exercise 1 with MERGE, first loading the data into a temporary table campaign_customers. Then compare the two solutions by answering these questions:
- What's the difference in
psql's output? - Which of the two is safe if two processes run the campaign at the same time?
- Which would you write if you also had to deactivate the customers not appearing in the file? Can it be done in PostgreSQL 16?
Exercise 3
A colleague wants to synchronise the catalogue and writes this:
-- ⚠️ INCORRECT
INSERT INTO products (name, category_id, supplier_id, price, cost, stock) VALUES
('Raw orange blossom honey 500 g', 1, 2, 10.20, 5.60, 40),
('Spelt pasta 500 g', 1, 2, 2.95, 1.40, 80),
('Raw orange blossom honey 500 g', 1, 2, 10.50, 5.75, 25)
ON CONFLICT (id) DO UPDATE
SET price = price,
stock = stock + EXCLUDED.stock;It has three distinct errors. Find them, explain the symptom of each one and rewrite the statement correctly, stating what would have to be declared in the schema for it to work and what result it would give.
Solutions
Solution 1
BEGIN;
INSERT INTO customers (name, last_name, email, city, country, signup_date) VALUES
('Lucía', 'Martínez Soler', 'lucia.martinez@example.com', 'Gandía', 'Spain', DATE '2026-03-02'),
('Sofia', 'Moreira Costa', 'sofia.moreira@example.pt', 'Coimbra', 'Portugal', DATE '2026-03-02'),
('Aitor', 'Zubizarreta Egaña', 'aitor.zubi@example.com', 'Bilbao', 'Spain', DATE '2026-03-02'),
('Nadia', 'Benali Torres', 'nadia.benali@example.com', 'Valencia', 'Spain', DATE '2026-03-02')
ON CONFLICT (email) DO UPDATE
SET city = EXCLUDED.city
WHERE customers.city IS DISTINCT FROM EXCLUDED.city
RETURNING id,
name || ' ' || last_name AS customer,
email,
city,
signup_date,
(xmax = 0) AS is_new;| id | customer | city | signup_date | is_new | |
|---|---|---|---|---|---|
| 1 | Lucía Martínez Soler | lucia.martinez@example.com | Gandía | 2025-01-10 | false |
| 7 | Sofia Moreira Costa | sofia.moreira@example.pt | Coimbra | 2025-03-21 | false |
| 18 | Aitor Zubizarreta Egaña | aitor.zubi@example.com | Bilbao | 2026-03-02 | true |
| 19 | Nadia Benali Torres | nadia.benali@example.com | Valencia | 2026-03-02 | true |
Four rows, and it's no accident that all four come out:
| Customer | What happens | Why |
|---|---|---|
| Lucía (1) | Update | She existed in Valencia, she moves to Gandía: the city changes |
| Sofia (7) | Update | She existed in Lisbon, she moves to Coimbra: the city changes |
| Aitor | Addition | New email |
| Nadia | Addition | New email |
If the file had brought Lucía with her current city (Valencia), the WHERE ... IS DISTINCT FROM would have discarded that update and the RETURNING would have given back three rows.
The brief's three decisions:
signup_dateoutside theSET. It appears in theINSERT(for the new ones) but not in theDO UPDATE, so the existing ones keep theirs: 2025-01-10 and 2025-03-21. It's the same principle asadded_datein 12.2.WHERE customers.city IS DISTINCT FROM EXCLUDED.city, not<>: if any customer had aNULLcity (the column allows it),<>would giveUNKNOWNand it would never be updated.(xmax = 0) AS is_new, with section 8's reservation: it's good for the campaign report, not for critical logic.
Solution 2
BEGIN;
CREATE TEMP TABLE campaign_customers (
email VARCHAR(120) PRIMARY KEY,
name VARCHAR(60) NOT NULL,
last_name VARCHAR(90) NOT NULL,
city VARCHAR(80),
country VARCHAR(60) NOT NULL
);
INSERT INTO campaign_customers (email, name, last_name, city, country) VALUES
('lucia.martinez@example.com', 'Lucía', 'Martínez Soler', 'Gandía', 'Spain'),
('sofia.moreira@example.pt', 'Sofia', 'Moreira Costa', 'Coimbra', 'Portugal'),
('aitor.zubi@example.com', 'Aitor', 'Zubizarreta Egaña', 'Bilbao', 'Spain'),
('nadia.benali@example.com', 'Nadia', 'Benali Torres', 'Valencia', 'Spain');
MERGE INTO customers AS c
USING campaign_customers AS k
ON c.email = k.email
WHEN MATCHED AND c.city IS DISTINCT FROM k.city THEN
UPDATE SET city = k.city
WHEN NOT MATCHED THEN
INSERT (name, last_name, email, city, country, signup_date)
VALUES (k.name, k.last_name, k.email, k.city, k.country, DATE '2026-03-02');SELECT id, name || ' ' || last_name AS customer, email, city, signup_date
FROM customers
WHERE email IN (SELECT email FROM campaign_customers)
ORDER BY id;| id | customer | city | signup_date | |
|---|---|---|---|---|
| 1 | Lucía Martínez Soler | lucia.martinez@example.com | Gandía | 2025-01-10 |
| 7 | Sofia Moreira Costa | sofia.moreira@example.pt | Coimbra | 2025-03-21 |
| 16 | Aitor Zubizarreta Egaña | aitor.zubi@example.com | Bilbao | 2026-03-02 |
| 17 | Nadia Benali Torres | nadia.benali@example.com | Valencia | 2026-03-02 |
An identical result in content, with one revealing detail in the ids: here they're 16 and 17, not 18 and 19 as with ON CONFLICT. The reason is that MERGE only evaluates the DEFAULTs of the rows it actually inserts, while the upsert evaluates them on the ones that end in a conflict too. MERGE wastes fewer sequence values. (Even so, don't count on consecutive ids with either of them: sequences have gaps by design.)
The three answers:
1. The output. MERGE 4 against INSERT 0 4, and above all: PostgreSQL 16's MERGE doesn't accept RETURNING, so you have to query afterwards to see what happened — and that query can no longer distinguish additions from updates. It's the most notable loss when switching statements.
2. Concurrency. The safe one is ON CONFLICT. If two processes launch the campaign at once, the MERGE can fail with duplicate key value violates unique constraint "customers_email_key", because its matching doesn't lean on the unique index in the same way. ON CONFLICT is designed precisely for that scenario.
3. Deactivating the absent ones. That would be the job of a WHEN NOT MATCHED BY SOURCE THEN UPDATE SET ... clause, which PostgreSQL 16 doesn't have (it arrived in 17). In 16 you have to do it in two statements inside the same transaction: the MERGE (or the upsert) and then an UPDATE ... WHERE email NOT IN (SELECT email FROM campaign_customers). Careful with that NOT IN and nulls: 04-02 warned about it, and here campaign_customers.email is a PRIMARY KEY, so it's safe. On top of that, customers has no deactivation column, so in GreenStore the operation wouldn't even be expressible without a prior ALTER TABLE — next lesson.
Solution 3
The three errors:
| # | Error | Symptom |
|---|---|---|
| 1 | ON CONFLICT (id) when the INSERT supplies no id at all |
Each row gets a fresh id from the sequence, so there's never a conflict: three duplicate products are inserted instead of the existing ones being updated. It raises no error |
| 2 | SET price = price with no EXCLUDED. |
A circular assignment: it assigns price its own value. It raises no error and it does nothing. It should have been EXCLUDED.price |
| 3 | The honey appears twice in the same VALUES |
With the conflict target fixed, PostgreSQL fails with ON CONFLICT DO UPDATE command cannot affect row a second time |
There's a fourth detail that isn't a syntax error but is a matter of judgement: SET stock = stock + EXCLUDED.stock works because an unqualified stock resolves to the existing row, but it's ambiguous to read. Always write products.stock + EXCLUDED.stock.
What the schema would need. To match by name you need section 5's constraint:
The corrected version, with the source deduplicated (we keep the honey's last shipment, the one at €10.50, and we add the two quantities: 40 + 25 = 65):
-- ✅ CORRECT
INSERT INTO products (name, category_id, supplier_id, price, cost, stock) VALUES
('Raw orange blossom honey 500 g', 1, 2, 10.50, 5.75, 65),
('Spelt pasta 500 g', 1, 2, 2.95, 1.40, 80)
ON CONFLICT (name) DO UPDATE
SET price = EXCLUDED.price,
cost = EXCLUDED.cost,
stock = products.stock + EXCLUDED.stock
WHERE products.price IS DISTINCT FROM EXCLUDED.price
OR EXCLUDED.stock > 0
RETURNING id, name, price, cost, stock, (xmax = 0) AS is_new;| id | name | price | cost | stock | is_new |
|---|---|---|---|---|---|
| 3 | Raw orange blossom honey 500 g | 10.50 | 5.75 | 145 | false |
| 4 | Spelt pasta 500 g | 2.95 | 1.40 | 230 | false |
Both products already existed (ids 3 and 4, from BioSierra Ibérica): the honey goes from €9.75 to €10.50 and from 80 to 145 units (80 + 65); the pasta goes from €2.80 to €2.95 and from 150 to 230 units (150 + 80).
And the exercise's underlying lesson: of the three errors, two produce no message at all. The ON CONFLICT (id) would have duplicated the catalogue in silence and the SET price = price would have left the prices untouched without anybody noticing. Only the third —the duplicate in the source— triggers a visible error. A badly written upsert fails quietly far more often than noisily, and that's why you have to check the RETURNING row by row the first time it goes into production.
Conclusion
The upsert solves the operation that was missing:
- The problem: "insert if it doesn't exist, update if it does", ubiquitous in catalogue synchronisation, goods receipt, customer signup and ratings.
- The naive solution is wrong:
SELECTand then decide opens a race condition between the check and the write. Two sessions see "it doesn't exist" and both insert. One statement is atomic; two aren't. INSERT ... ON CONFLICT, with its two actions:DO NOTHING(ideal for rerunnable master data,INSERT 0 0with no error) andDO UPDATE SET(the upsert proper).- The conflict target, by column or by
ON CONSTRAINT, and why it requires a unique constraint: without the index, PostgreSQL couldn't decide atomically and the race window would come back. EXCLUDED, the pseudo-table with the proposed row, againsttable.columnwith the existing row. Every pattern comes out of those two: keep the new one, keep the old one,GREATEST, and above all accumulate (stock = products.stock + EXCLUDED.stock). And its limit: the same key value twice in one statement fails, you have to deduplicate the source.WHEREin theDO UPDATEwithIS DISTINCT FROM(04-03) so as not to write when nothing changes: fewer writes, fewer locks, honest auditing.RETURNINGwith an upsert, and thexmax = 0trick to tell an addition from an update, with its reservations.MERGE, the SQL:2003 standard available since PostgreSQL 15:WHEN MATCHED/WHEN NOT MATCHED, several conditions evaluated in order and the ability to delete. With its three limitations in PostgreSQL 16: noRETURNING, noWHEN NOT MATCHED BY SOURCE, and not immune to concurrency.- When to use each:
ON CONFLICTfor the classic case with real concurrency;MERGEfor multiple rules, matchings without a unique key and portability. - The support by engine, and the warning about SQLite's
INSERT OR REPLACE, which deletes and recreates the row: it loses columns, it changes therowidand it fires the delete cascades.
With this you've closed the DML: you know how to create, read, insert, modify, delete and merge. All of it over a schema that has so far been immutable: the same one you created in 05-01 and haven't touched since. But schemas change. A new column is needed, a type turns out to be too small, a constraint arrives late, a name turns out to be a mistake. In the module's last lesson, Changing the Schema: ALTER TABLE and Safe Migrations, you'll learn all the ALTER TABLE operations and —what really separates a harmless migration from a production outage— which ones lock the table and which don't, the expand/contract pattern for changing a schema without stopping the service, and why no structural change should ever be typed by hand into a production console.
SQL Course
Module 1: Introduction to SQL
- What is SQL?
- Setting up your SQL environment
- Basic SQL syntax
- Understanding databases and tables
- The relational model: primary and foreign keys
- The course database: GreenStore
Module 2: Basic SQL queries
- The SELECT statement
- Aliases, expressions and calculated columns
- Filtering data with WHERE
- DISTINCT and removing duplicates
- Sorting data with ORDER BY
- Limiting results with LIMIT
Module 3: Working with multiple tables
- JOIN operations
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN and CROSS JOIN
- Set operations: UNION, INTERSECT and EXCEPT
Module 4: Advanced data filtering
- Using LIKE for pattern matching
- The IN and BETWEEN operators
- NULL values and IS NULL
- Aggregate functions: COUNT, SUM, AVG, MIN and MAX
- Aggregating data with GROUP BY
- The HAVING clause
Module 5: Data manipulation
- Creating tables and constraints with CREATE TABLE
- The INSERT statement
- The UPDATE statement
- The DELETE statement
- The UPSERT (MERGE) statement
- Changing the schema: ALTER TABLE and safe migrations
Module 6: Advanced SQL functions
- String functions
- Numeric functions
- Date and time functions
- Type conversion and handling NULL: CAST and COALESCE
- Conditional expressions
Module 7: Subqueries and nested queries
- Introduction to subqueries
- Correlated subqueries
- EXISTS and NOT EXISTS
- Using subqueries in SELECT, FROM and WHERE
- Subquery or JOIN: which one to choose
Module 8: Indexes and performance tuning
- Understanding indexes
- Creating and managing indexes
- Index types and when not to index
- Query optimization techniques
- Analyzing query performance
Module 9: Transactions and concurrency
- Introduction to transactions
- ACID properties
- Transaction control statements
- Isolation levels and concurrency anomalies
- Handling concurrency: locks and deadlocks
Module 10: Advanced topics
- Views
- Common table expressions (CTEs)
- Window functions
- Stored procedures
- Triggers
- JSON and semi-structured data
Module 11: SQL in practice
- Real-world use cases
- Best practices
- Security: SQL injection, permissions and roles
- SQL for data analysis
- SQL in web development
