Postgres Upsert: INSERT ... ON CONFLICT (and MERGE) Explained
Insert a row, or update it if it already exists, atomically. ON CONFLICT DO NOTHING and DO UPDATE with EXCLUDED, conflict targets and partial indexes, conditional updates, bulk upserts, RETURNING, MERGE, the race conditions a select-then-insert has, and upserts in Prisma and Drizzle.
An upsert means "insert this row — or, if it already exists, update it instead". Settings per user, counters, syncing data from another system, caching API results: all upserts.
Why not just check first?
const existing = await db.query('SELECT 1 FROM settings WHERE user_id = $1', [id])
if (existing.rows.length) await db.query('UPDATE ...')
else await db.query('INSERT ...')
Two requests running at the same time can both see "doesn't exist" and both insert — causing a duplicate or a unique constraint error. Postgres's ON CONFLICT does it atomically. (Database transactions)
ON CONFLICT DO NOTHING
Insert unless it already exists:
INSERT INTO newsletter_subscribers (email)
VALUES ('ada@example.com')
ON CONFLICT (email) DO NOTHING;
ON CONFLICT DO UPDATE
INSERT INTO user_settings (user_id, theme, updated_at)
VALUES (42, 'dark', now())
ON CONFLICT (user_id)
DO UPDATE SET theme = EXCLUDED.theme,
updated_at = EXCLUDED.updated_at;
EXCLUDED is the row you tried to insert. So "set theme to the new value".
The conflict target needs a unique index
ON CONFLICT (user_id) only works if there's a unique constraint or unique index on user_id (or a primary key). Without one:
ERROR: there is no unique or exclusion constraint matching the ON CONFLICT specification
ALTER TABLE user_settings ADD CONSTRAINT user_settings_user_id_key UNIQUE (user_id);
Multi-column keys work: ON CONFLICT (user_id, key). For a partial unique index, repeat its condition: ON CONFLICT (email) WHERE deleted_at IS NULL.
Counters
INSERT INTO page_views (page, views)
VALUES ('/pricing', 1)
ON CONFLICT (page)
DO UPDATE SET views = page_views.views + 1;
page_views.views is the existing value; EXCLUDED.views the attempted one.
Only update when something changed
INSERT INTO products (sku, name, price)
VALUES ('LAMP-1', 'Desk Lamp', 39.00)
ON CONFLICT (sku)
DO UPDATE SET name = EXCLUDED.name, price = EXCLUDED.price
WHERE (products.name, products.price) IS DISTINCT FROM (EXCLUDED.name, EXCLUDED.price);
Skips writes (and dead row versions) when nothing changed — useful for syncs that run often. (Postgres VACUUM and bloat, NULL in SQL)
Get the row back
INSERT INTO user_settings (user_id, theme) VALUES (42, 'dark')
ON CONFLICT (user_id) DO UPDATE SET theme = EXCLUDED.theme
RETURNING *;
(With DO NOTHING, conflicting rows return nothing.)
Bulk upserts
INSERT INTO products (sku, name, price)
VALUES ('A', 'Lamp', 39), ('B', 'Chair', 120), ('C', 'Desk', 300)
ON CONFLICT (sku) DO UPDATE SET name = EXCLUDED.name, price = EXCLUDED.price;
One statement for many rows — far faster than one per row. If the same key appears twice in one statement, Postgres errors (ON CONFLICT DO UPDATE command cannot affect row a second time) — de-duplicate the input first. For large imports, load into a staging table, then upsert from it. (Import a CSV into Postgres)
MERGE
Postgres 15+ supports SQL-standard MERGE, more flexible when you need different actions by condition (including deletes):
MERGE INTO inventory i
USING incoming n ON i.sku = n.sku
WHEN MATCHED AND n.quantity = 0 THEN DELETE
WHEN MATCHED THEN UPDATE SET quantity = n.quantity
WHEN NOT MATCHED THEN INSERT (sku, quantity) VALUES (n.sku, n.quantity);
For a simple insert-or-update, ON CONFLICT is shorter and handles concurrent inserts more gracefully; MERGE can still hit unique violations under concurrency.
Sequence gaps
Every INSERT attempt consumes a value from an identity/serial sequence even when it ends up updating. Expect gaps in IDs — that's normal. (UUID vs auto-increment)
In ORMs
// Prisma
await prisma.userSettings.upsert({
where: { userId: 42 },
update: { theme: 'dark' },
create: { userId: 42, theme: 'dark' },
})
// Drizzle
await db.insert(userSettings).values({ userId: 42, theme: 'dark' })
.onConflictDoUpdate({ target: userSettings.userId, set: { theme: 'dark' } })
Prisma's upsert uses native ON CONFLICT when the query shape allows; otherwise it falls back to select-then-write, so keep where on a unique field.
EasySpawn gives each app its own Postgres database with Claude Code alongside, which can turn a racy select-then-insert into a proper atomic upsert. See how it works or join the waitlist.
Related: duplicate key value violates unique constraint · Database Transactions Explained · Postgres CTEs · Idempotency Keys
Keep reading
Postgres timestamp vs timestamptz: Which Should You Use?
timestamptz stores an absolute moment in time; timestamp stores a wall-clock reading with no zone. Why timestamptz is almost always right, what Postgres actually stores, how the session TimeZone changes what you see, AT TIME ZONE, date_trunc by local day, ORMs and drivers, and migrating.
Chunking for RAG: How to Split Documents So Retrieval Works
How you split documents into chunks decides what a RAG system can retrieve. Fixed-size, recursive, structure-aware and semantic chunking, choosing chunk size and overlap, adding context to chunks, parent-child retrieval, chunking code and tables, and how to evaluate it.