Upsert with ON CONFLICT
Sometimes you want to insert a row, but if a matching one already exists you would rather update it than fail. Doing this by hand — check first, then insert or update — is racy: two requests can both see “no row” and both try to insert. PostgreSQL solves this atomically with INSERT ... ON CONFLICT, often called upsert (update plus insert).
Everything below was run on PostgreSQL 18.6 (postgres:18.6, aarch64, Docker Desktop on a Mac, September 2026). The single-session examples are sql fences run top to bottom in one empty database. The concurrency examples are real two-session runs (two psql processes started at the same time), printed exactly as they came back; timings are from this Mac.
Why not check first?
Section titled “Why not check first?”Here is check-then-insert with two sessions, on a table with no unique constraint. Each checks that the email is unused, thinks for a second, and inserts. Both saw zero rows, so both insert:
-- session A: scriptBEGIN;SELECT count(*) AS a_sees FROM signups WHERE email = '[email protected]';\o /dev/nullSELECT pg_sleep(1);\oINSERT INTO signups (email) VALUES ('[email protected]');COMMIT;-- session A: psql outputBEGIN a_sees-------- 0(1 row)
INSERT 0 1COMMIT
-- session B (same script, run at the same time): scriptBEGIN;SELECT count(*) AS b_sees FROM signups WHERE email = '[email protected]';\o /dev/nullSELECT pg_sleep(1);\oINSERT INTO signups (email) VALUES ('[email protected]');COMMIT;-- session B (same script, run at the same time): psql outputBEGIN b_sees-------- 0(1 row)
INSERT 0 1COMMIT
-- afterwards, from any session:SELECT email, count(*) FROM signups GROUP BY 1; email | count-----------------+-------(1 row)Two rows with the same email. This is the race that a unique constraint plus a single atomic statement removes.
The unique constraint
Section titled “The unique constraint”For these examples the books table gets a unique constraint on title, so no two books can share the exact same title. A plain duplicate insert now fails with 23505:
-- sql/setup.sqlCREATE TABLE authors ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text NOT NULL, born int);CREATE TABLE books ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, title text NOT NULL, author_id bigint REFERENCES authors (id), published int, copies_sold int NOT NULL DEFAULT 0);CREATE TABLE sales ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, book_id bigint REFERENCES books (id), region text NOT NULL, copies int NOT NULL);INSERT INTO authors (name, born) VALUES ('Mara Linde', 1968), ('Owen Pryce', 1975), ('Sela Vance', 1990);INSERT INTO books (title, author_id, published, copies_sold) VALUES ('Quiet Harbors', 1, 2003, 450), ('The Glass Orchard', 1, 2018, 1250), ('Tidal Notes', 2, 2011, 600), ('Unlinked Draft', NULL, 2020, 0);INSERT INTO sales (book_id, region, copies) VALUES (2, 'East', 900), (3, 'West', 400), (1, 'West', 400), (3, 'East', 200), (2, 'West', 150), (1, 'East', 50);-- sql/books-title-unique.sqlALTER TABLE books ADD CONSTRAINT books_title_key UNIQUE (title);-- @expect-error 23505INSERT INTO books (title, author_id, published) VALUES ('The Glass Orchard', 1, 2018);ERROR: duplicate key value violates unique constraint "books_title_key"DETAIL: Key (title)=(The Glass Orchard) already exists.How upsert decides
Section titled “How upsert decides”When the insert would violate the named unique constraint, PostgreSQL does not error out; instead it runs the action in your ON CONFLICT clause — either updating the existing row or quietly doing nothing.
flowchart TD
A["INSERT row"] --> B{"Conflicts with unique constraint?"}
B -- No --> C["Insert the new row"]
B -- Yes --> D{"ON CONFLICT action"}
D -- DO UPDATE --> E["Update the existing row"]
D -- DO NOTHING --> F["Leave the existing row unchanged"] DO UPDATE with EXCLUDED
Section titled “DO UPDATE with EXCLUDED”You name the column (or constraint) that defines a conflict, then describe how to update. Inside the update, the special EXCLUDED table holds the values you tried to insert, so you can copy them into the existing row. Each example below is in a rolled-back transaction, so the dataset stays the same.
-- sql/upsert-do-update.sqlBEGIN;INSERT INTO books (title, author_id, published, copies_sold)VALUES ('The Glass Orchard', 1, 2018, 1300)ON CONFLICT (title)DO UPDATE SET published = EXCLUDED.published, copies_sold = EXCLUDED.copies_soldRETURNING id, title, copies_sold;ROLLBACK;const res = await pool.query( `INSERT INTO books (title, author_id, published, copies_sold) VALUES ($1, $2, $3, $4) ON CONFLICT (title) DO UPDATE SET published = EXCLUDED.published, copies_sold = EXCLUDED.copies_sold RETURNING id, title, copies_sold`, ['The Glass Orchard', 1, 2018, 1300],);cur.execute( """ INSERT INTO books (title, author_id, published, copies_sold) VALUES (%s, %s, %s, %s) ON CONFLICT (title) DO UPDATE SET published = EXCLUDED.published, copies_sold = EXCLUDED.copies_sold RETURNING id, title, copies_sold """, ("The Glass Orchard", 1, 2018, 1300),)rows, err := conn.Query(ctx, `INSERT INTO books (title, author_id, published, copies_sold) VALUES ($1, $2, $3, $4) ON CONFLICT (title) DO UPDATE SET published = EXCLUDED.published, copies_sold = EXCLUDED.copies_sold RETURNING id, title, copies_sold`, "The Glass Orchard", 1, 2018, 1300)let rows = sqlx::query( "INSERT INTO books (title, author_id, published, copies_sold) VALUES ($1, $2, $3, $4) ON CONFLICT (title) DO UPDATE SET published = EXCLUDED.published, copies_sold = EXCLUDED.copies_sold RETURNING id, title, copies_sold",).bind("The Glass Orchard").bind(1_i64).bind(2018_i32).bind(1300_i32).fetch_all(&pool).await?;BEGIN id | title | copies_sold----+-------------------+------------- 2 | The Glass Orchard | 1300(1 row)
INSERT 0 1ROLLBACKBecause a book titled “The Glass Orchard” already exists (id 2), the insert turns into an update of that row. Run the same statement with a brand-new title and there is no conflict, so it inserts normally and returns the freshly generated id. That id is 7, not 5: the failed insert above and the earlier attempts each consumed an identity value (more on that below).
-- sql/upsert-new-row.sqlBEGIN;INSERT INTO books (title, author_id, published, copies_sold)VALUES ('Salt Road', 3, 2024, 10)ON CONFLICT (title)DO UPDATE SET copies_sold = EXCLUDED.copies_soldRETURNING id, title, copies_sold;ROLLBACK;BEGIN id | title | copies_sold----+-----------+------------- 7 | Salt Road | 10(1 row)
INSERT 0 1ROLLBACKIn pgAdmin: run the statement twice in the Query Tool — the first run inserts, the second hits the conflict and updates. The Data Output grid shows the returned row each time.
How do you tell an insert from an update in the result? PostgreSQL 18’s RETURNING old.x, new.x does it: old is NULL when the row was inserted. The older trick is xmax = 0 (a system column that is 0 for a freshly inserted row), shown in the last column:
-- sql/upsert-returning-old-new.sqlBEGIN;INSERT INTO books (title, author_id, published, copies_sold) VALUES ('The Glass Orchard', 1, 2018, 1300), ('Salt Road', 3, 2024, 10)ON CONFLICT (title)DO UPDATE SET copies_sold = EXCLUDED.copies_soldRETURNING title, old.copies_sold AS was, new.copies_sold AS now, (xmax = 0) AS inserted;ROLLBACK;BEGIN title | was | now | inserted-------------------+------+------+---------- The Glass Orchard | 1250 | 1300 | f Salt Road | | 10 | t(2 rows)
INSERT 0 2ROLLBACKA WHERE on DO UPDATE limits when the update happens. Below, the conflict is detected, the guard is false, and nothing changes: no row is updated and RETURNING returns nothing (so a RETURNING on an upsert can be empty even though a conflict occurred):
-- sql/upsert-where-guard.sqlBEGIN;INSERT INTO books (title, author_id, published, copies_sold)VALUES ('The Glass Orchard', 1, 2018, 100)ON CONFLICT (title)DO UPDATE SET copies_sold = EXCLUDED.copies_soldWHERE EXCLUDED.copies_sold > books.copies_soldRETURNING id, title, copies_sold;SELECT copies_sold FROM books WHERE id = 2;ROLLBACK;BEGIN id | title | copies_sold----+-------+-------------(0 rows)
INSERT 0 0 copies_sold------------- 1250(1 row)
ROLLBACKDO NOTHING
Section titled “DO NOTHING”If you only want to insert when the row is new, and silently skip duplicates, use DO NOTHING. No error, no update.
INSERT INTO books (title, author_id, published)VALUES ('The Glass Orchard', 1, 2018)ON CONFLICT (title) DO NOTHING;const res = await pool.query( 'INSERT INTO books (title, author_id, published) VALUES ($1, $2, $3) ON CONFLICT (title) DO NOTHING', ['The Glass Orchard', 1, 2018],);console.log(res.rowCount); // 0 when the row already existedcur.execute( "INSERT INTO books (title, author_id, published) VALUES (%s, %s, %s) ON CONFLICT (title) DO NOTHING", ("The Glass Orchard", 1, 2018),)# cur.rowcount is 0 when nothing was insertedtag, err := conn.Exec(ctx, "INSERT INTO books (title, author_id, published) VALUES ($1, $2, $3) ON CONFLICT (title) DO NOTHING", "The Glass Orchard", 1, 2018)// tag.RowsAffected() is 0 when the row already existedlet result = sqlx::query( "INSERT INTO books (title, author_id, published) VALUES ($1, $2, $3) ON CONFLICT (title) DO NOTHING",).bind("The Glass Orchard").bind(1_i64).bind(2018_i32).execute(&pool).await?;// result.rows_affected() is 0 when nothing was insertedWhen the row already exists, zero rows are affected (INSERT 0 0) and RETURNING is empty; when it is new, one row is inserted (INSERT 0 1) and its id comes back. The second statement inserts a new book, then the third removes it again:
-- sql/upsert-do-nothing.sqlINSERT INTO books (title, author_id, published)VALUES ('The Glass Orchard', 1, 2018)ON CONFLICT (title) DO NOTHINGRETURNING id;INSERT INTO books (title, author_id, published)VALUES ('Salt Road', 3, 2024)ON CONFLICT (title) DO NOTHINGRETURNING id;DELETE FROM books WHERE title = 'Salt Road'; id----(0 rows)
INSERT 0 0 id---- 12(1 row)
INSERT 0 1DELETE 1All four drivers, run once against a scratch database (the two variants above, with the row count for DO NOTHING):
$ (each snippet run once against the course dataset in a scratch database, reset before every run)[DO UPDATE ... RETURNING] Node.js (pg 8.23.0): [ { id: '2', title: 'The Glass Orchard', copies_sold: 1300 } ][DO UPDATE ... RETURNING] Python (psycopg 3.3.6): [(2, 'The Glass Orchard', 1300)][DO UPDATE ... RETURNING] Go (pgx v5.11.0): [2 The Glass Orchard 1300][DO UPDATE ... RETURNING] Rust (sqlx 0.9.0): 2 The Glass Orchard 1300[DO NOTHING (rows affected)] Node.js (pg 8.23.0): 0[DO NOTHING (rows affected)] Python (psycopg 3.3.6): 0[DO NOTHING (rows affected)] Go (pgx v5.11.0): 0[DO NOTHING (rows affected)] Rust (sqlx 0.9.0): 0The errors you will meet
Section titled “The errors you will meet”ON CONFLICT DO UPDATE needs to know which unique constraint defines “the same row”. Without a target it is refused (42601); with a column that has no unique constraint it fails with 42P10 (DO NOTHING may omit the target, but a named target must match a unique index):
-- @expect-error 42601INSERT INTO books (title, author_id, published) VALUES ('Salt Road', 3, 2024)ON CONFLICT DO UPDATE SET published = EXCLUDED.published;ERROR: ON CONFLICT DO UPDATE requires inference specification or constraint nameLINE 2: ON CONFLICT DO UPDATE SET published = EXCLUDED.published; ^HINT: For example, ON CONFLICT (column_name).-- @expect-error 42P10INSERT INTO books (title, author_id, published) VALUES ('Salt Road', 3, 2024)ON CONFLICT (published) DO NOTHING;ERROR: there is no unique or exclusion constraint matching the ON CONFLICT specificationOne more, when a single statement proposes two rows that hit the same existing row. PostgreSQL will not update one row twice in one command (21000):
-- @expect-error 21000INSERT INTO books (title, author_id, published, copies_sold) VALUES ('Tidal Notes', 2, 2011, 1), ('Tidal Notes', 2, 2011, 2)ON CONFLICT (title) DO UPDATE SET copies_sold = EXCLUDED.copies_sold;ERROR: ON CONFLICT DO UPDATE command cannot affect row a second timeHINT: Ensure that no rows proposed for insertion within the same command have duplicate constrained values.Upsert and identity gaps
Section titled “Upsert and identity gaps”The insert is attempted before the conflict is detected, so a conflicting row still consumes an identity value. Two conflicts here burn ids 3 and 4, and the next real insert gets 5 (books above jumped for the same reason). Identity columns are unique, not gapless:
-- sql/identity-gap.sqlCREATE TABLE tags (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, label text NOT NULL UNIQUE);INSERT INTO tags (label) VALUES ('sql'), ('postgres');INSERT INTO tags (label) VALUES ('sql'), ('postgres') ON CONFLICT (label) DO NOTHING;INSERT INTO tags (label) VALUES ('upsert') RETURNING id;CREATE TABLEINSERT 0 2INSERT 0 0 id---- 5(1 row)
INSERT 0 1Atomicity, proved
Section titled “Atomicity, proved”Same two-session setup as the racy example, now with a primary key and ON CONFLICT DO NOTHING. Session A inserts and holds its transaction open for 2 seconds; session B tries the same email half a second later. B does not fail and does not duplicate: it waits for A, then finds the conflict and inserts nothing:
-- session A: scriptBEGIN;INSERT INTO signups (email, name) VALUES ('[email protected]', 'from A') ON CONFLICT (email) DO NOTHING;\o /dev/nullSELECT pg_sleep(2);\oCOMMIT;-- session A: psql outputBEGININSERT 0 1COMMIT
-- session B (started 0.5 s later): script\o /dev/nullSELECT pg_sleep(0.5);\o\timing onINSERT INTO signups (email, name) VALUES ('[email protected]', 'from B') ON CONFLICT (email) DO NOTHING;-- session B (started 0.5 s later): psql outputTiming is on.INSERT 0 0Time: 1505.512 ms (00:01.506)
-- afterwards:SELECT * FROM signups; email | name-----------------+-------- [email protected] | from A(1 row)B waited 1.5 seconds (until A committed), reported INSERT 0 0, and the table holds one row. That is the guarantee: exactly one insert, whichever session wins.
Isolation level matters. At REPEATABLE READ, a transaction works from a snapshot. If the conflicting row was changed by someone else after that snapshot, DO UPDATE cannot silently update a row the transaction never saw in that state, so it raises a serialization failure and you must retry the whole transaction (docs):
-- session A (starts 1 s in): script\o /dev/nullSELECT pg_sleep(1);\oUPDATE signups SET name = 'changed by A' WHERE email = '[email protected]';-- session A (starts 1 s in): psql outputUPDATE 1
-- session B: scriptBEGIN ISOLATION LEVEL REPEATABLE READ;SELECT name AS b_snapshot_sees FROM signups;\o /dev/nullSELECT pg_sleep(2);\oINSERT INTO signups (email, name) VALUES ('[email protected]', 'from B')ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name;COMMIT;-- session B: psql outputBEGIN b_snapshot_sees----------------- ada(1 row)
ERROR: could not serialize access due to concurrent updateROLLBACKMERGE: the standard-SQL cousin
Section titled “MERGE: the standard-SQL cousin”MERGE (PostgreSQL 15+, with RETURNING and merge_action() since 17) expresses the same intent in standard SQL and is more general: several WHEN branches, conditions, and DELETE. It does not need a unique constraint. The price is that it is not an atomic upsert:
-- sql/merge.sqlBEGIN;MERGE INTO books AS bUSING (VALUES ('The Glass Orchard', 1, 2018, 1300), ('Salt Road', 3, 2024, 10)) AS v (title, author_id, published, copies_sold)ON b.title = v.titleWHEN MATCHED THEN UPDATE SET copies_sold = v.copies_soldWHEN NOT MATCHED THEN INSERT (title, author_id, published, copies_sold) VALUES (v.title, v.author_id, v.published, v.copies_sold)RETURNING merge_action() AS action, b.id, b.title, b.copies_sold;ROLLBACK;BEGIN action | id | title | copies_sold--------+----+-------------------+------------- UPDATE | 2 | The Glass Orchard | 1300 INSERT | 15 | Salt Road | 10(2 rows)
MERGE 2ROLLBACKTwo sessions running the same MERGE for a key that does not exist yet both take the NOT MATCHED branch, and the loser gets a unique violation instead of DO NOTHING’s quiet skip:
-- session A: scriptBEGIN;MERGE INTO signups s USING (VALUES ('[email protected]', 'from A')) v(email, name) ON s.email = v.emailWHEN MATCHED THEN UPDATE SET name = v.nameWHEN NOT MATCHED THEN INSERT (email, name) VALUES (v.email, v.name);\o /dev/nullSELECT pg_sleep(2);\oCOMMIT;-- session A: psql outputBEGINMERGE 1COMMIT
-- session B (started 0.5 s later): script\o /dev/nullSELECT pg_sleep(0.5);\o\timing onMERGE INTO signups s USING (VALUES ('[email protected]', 'from B')) v(email, name) ON s.email = v.emailWHEN MATCHED THEN UPDATE SET name = v.nameWHEN NOT MATCHED THEN INSERT (email, name) VALUES (v.email, v.name);-- session B (started 0.5 s later): psql outputTiming is on.ERROR: duplicate key value violates unique constraint "signups_pkey"DETAIL: Key (email)=([email protected]) already exists.Time: 1502.088 ms (00:01.502)Use ON CONFLICT for “insert or update this key, concurrently, exactly once”; use MERGE for set-based synchronisation from a source you control, and be ready to retry on 23505.
Reading AI-generated code
Section titled “Reading AI-generated code”An assistant is told the table is unique per title and author, and writes an upsert whose comment says exactly that:
🐛 Spot the bug
-- AI comment: "editions are unique per (title, author) - upsert on title"CREATE TABLE editions (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,title text NOT NULL,author_id bigint NOT NULL,price numeric(8, 2) NOT NULL,UNIQUE (title, author_id));
INSERT INTO editions (title, author_id, price)VALUES ('Quiet Harbors', 1, 12.00)ON CONFLICT (title) DO UPDATE SET price = EXCLUDED.price;Show the answer
The comment is right and the code is wrong. ON CONFLICT (title) must match a unique index on exactly title; the table only has one on (title, author_id). The statement fails as soon as it runs, in production, with 42P10:
-- sql/editions.sqlCREATE TABLE editions ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, title text NOT NULL, author_id bigint NOT NULL, price numeric(8, 2) NOT NULL, UNIQUE (title, author_id));-- @expect-error 42P10INSERT INTO editions (title, author_id, price)VALUES ('Quiet Harbors', 1, 12.00)ON CONFLICT (title) DO UPDATE SET price = EXCLUDED.price;ERROR: there is no unique or exclusion constraint matching the ON CONFLICT specificationThe fix is to name the real conflict target: the column list of the unique index, or the constraint by name. Both forms upsert the same row:
-- sql/editions-fixed.sqlINSERT INTO editions (title, author_id, price)VALUES ('Quiet Harbors', 1, 12.00)ON CONFLICT (title, author_id) DO UPDATE SET price = EXCLUDED.priceRETURNING id, title, price;INSERT INTO editions (title, author_id, price)VALUES ('Quiet Harbors', 1, 14.50)ON CONFLICT ON CONSTRAINT editions_title_author_id_key DO UPDATE SET price = EXCLUDED.priceRETURNING id, title, price; id | title | price----+---------------+------- 1 | Quiet Harbors | 12.00(1 row)
INSERT 0 1 id | title | price----+---------------+------- 1 | Quiet Harbors | 14.50(1 row)
INSERT 0 1Mental Model
Section titled “Mental Model”An upsert is an insert that carries its own answer to “what if this key exists?”. The database implements it by inserting into the unique index first, which serialises concurrent writers on the key: the second writer waits for the first, then sees the conflict. That is why it is atomic where check-then-insert is not, why it needs a unique index to point at, and why every attempt, successful or not, consumes an identity value.
Tips / gotchas
Section titled “Tips / gotchas”- Upsert needs a unique constraint or unique index to detect a conflict, and the
ON CONFLICTtarget must match it exactly (42P10otherwise). - The
EXCLUDEDtable refers to the row you tried to insert. Use it inDO UPDATEto pull the new values into the existing row. - You can add a
WHEREtoDO UPDATEto update only under certain conditions, for exampleDO UPDATE SET copies_sold = EXCLUDED.copies_sold WHERE EXCLUDED.copies_sold > books.copies_sold; the row is then neither updated nor returned. DO NOTHINGreturns zero affected rows and an emptyRETURNINGon a conflict, so check the row count if you need to know whether an insert actually happened.- One statement cannot conflict with the same existing row twice (
21000); deduplicate the input first. - Under
REPEATABLE READorSERIALIZABLE, be ready to retry on40001. - Keep passing values as placeholders here too; upsert is still an
INSERT, and the same SQL injection rules apply.
Trade-offs
Section titled “Trade-offs”| Option | Benefit | Cost |
|---|---|---|
INSERT ... ON CONFLICT | Atomic, one round trip, no race condition (proved above) | Needs a unique constraint or unique index for the conflict to target; burns identity values on conflicts |
| Check-then-insert in application code | Easy to read, logic is straightforward | A classic race condition when multiple requests run at once (two rows above) |
DO UPDATE on conflict | Data is always synced to the latest value | Can unintentionally overwrite existing values if you are not careful |
DO NOTHING on conflict | Safe, never overwrites existing data | Duplicate events are silently dropped even when an update was actually needed |
MERGE | Standard SQL, several branches, no unique constraint needed | Concurrent runs can fail with unique violations (proved above) |
Common Misconceptions
Section titled “Common Misconceptions”- “ON CONFLICT DO NOTHING means nothing happens.” The insert is still attempted: an identity value is consumed and a lock may be waited for (B waited 1.5 s).
- “RETURNING always returns the row.” Not for
DO NOTHING, and not when aDO UPDATE ... WHEREis false. - “MERGE is a better upsert.” It is more general, not more atomic (the race4 run).
- “Any unique column can be the target.” The target must match a unique index exactly; a composite index does not serve a single-column target.
- “Upsert is safe at any isolation level.” At
REPEATABLE READit can fail with40001and needs a retry loop.