postgresql

MERGE is not the upsert you already had

MERGE is not the upsert you already had

PostgreSQL 15 was released on the thirteenth of October, and the headline everywhere was MERGE. It is a genuine gap closed — the statement is in the SQL standard, other databases have had it for years, and it has been on the list of things people say Postgres does not have for as long as I have been writing SQL.

The reaction I kept seeing was "Postgres finally has upsert". Postgres has had upsert since 9.5. MERGE is not that feature arriving late under a standard name, and treating it as a drop-in replacement is how you find out the difference in production rather than on a Saturday.

What we already had

INSERT ... ON CONFLICT is not standard SQL. It is a Postgres invention, and it is narrow on purpose: insert this row, and if it collides with a unique index or constraint, do something else instead.

INSERT INTO device_state (device_id, last_seen, battery)
VALUES ($1, $2, $3)
ON CONFLICT (device_id)
DO UPDATE SET last_seen = EXCLUDED.last_seen,
              battery   = EXCLUDED.battery;

The important word is index. The statement does not look up the row and then decide. It attempts the insert and discovers the conflict through the index, which is the whole reason it behaves well when several connections run it at once. There is no window between checking and acting, because there is no check.

What MERGE is

MERGE is a join with branches attached.

MERGE INTO device_state AS t
USING incoming_batch   AS s
   ON t.device_id = s.device_id
WHEN MATCHED AND s.battery IS NULL THEN
  DELETE
WHEN MATCHED THEN
  UPDATE SET last_seen = s.last_seen,
             battery   = s.battery
WHEN NOT MATCHED THEN
  INSERT (device_id, last_seen, battery)
  VALUES (s.device_id, s.last_seen, s.battery);

Read it as: join these two relations on that condition, and for each row of the result take the first branch whose condition holds. The driver is the ON clause — any join condition you like — not a unique constraint. Each branch can carry its own extra condition, and the actions can be INSERT, UPDATE or DELETE in one pass.

That is genuinely more expressive than ON CONFLICT, which has exactly two outcomes and requires a unique index to find them. If what you are describing is "reconcile this batch against that table", MERGE says it in one statement where before you would have written three.

The difference that will bite you

MERGE evaluates the join and then acts on the result. That ordering is the entire problem.

Two connections running the same MERGE for a device_id that does not exist yet can both evaluate the join, both find no match, and both take the WHEN NOT MATCHED branch. One of them inserts. The other hits the unique index and raises a unique violation — it does not quietly fall through to the MATCHED branch, because that decision was already made. Under a stricter isolation level you get a serialization failure instead, which is at least a more honest error.

ON CONFLICT cannot do this to you, because it never made a decision it has to stand behind. The conflict is discovered by the insert itself.

So the rule I came away with is unglamorous: MERGE needs either a retry loop around it, or knowledge that nothing else is writing those rows concurrently. It is not a concurrency-safe upsert with nicer syntax. It is a batch statement that assumes you have thought about who else is holding the table.

The other restrictions in 15

Three more, all of which I found by trying:

  • There is no RETURNING. If you need to know which rows the statement touched, you are back to doing it another way.
  • The target has to be a real table. Pointing MERGE at a view does not work.
  • There is no branch for "rows in the target that the source never mentioned". You can DELETE a row the source matched and asked you to remove, but "delete everything the batch did not include" is still a separate statement.

That last one matters, because "make this table look like that one" is the job people reach for MERGE to do, and in 15 it does not quite reach.

Where I would actually use it

Batch reconciliation, where the source is a staging table or a VALUES list, the job is the only writer for those keys, and the work genuinely needs insert, update and delete decided per row against a join. That is an ETL shape, it is a real and common one, and writing it as one statement over one scan instead of three statements over three is a real improvement.

For "the application received an event, write the current state", ON CONFLICT is still the answer and I do not expect that to change.

The thing in 15 that is more likely to break your upgrade

Worth saying, because it has nothing to do with MERGE and it will affect far more people: 15 removes the default CREATE privilege on the public schema.

Previously any user could create objects in public. Now they cannot unless granted. Every migration that does CREATE TABLE something as a non-superuser, against a database where nobody ever thought about schemas, stops working after the upgrade. Any application that has been quietly relying on the old default — which is most applications older than a few years — needs a grant, or a schema of its own, before that upgrade lands.

The rest of the release is the usual good, quiet work: server-side compression options including zstd for pg_basebackup, structured logging in JSON, row and column filtering for logical replication publications, and sorting improvements in memory and on disk.

What I concluded over a weekend

If the operation fits INSERT ... ON CONFLICT, use it. It is narrower, it is not portable, and it is correct under concurrency without any effort from me, which is the property I care about most in a statement that runs on every request.

Reach for MERGE when what you are expressing really is a join with branches, when it runs as a batch rather than per request, and when you can answer the question "who else is writing these rows right now". If you cannot answer that question, the standard syntax is not the feature you need.

Deyan Peev

Written by

Deyan Peev

Founding Engineer · Sofia, Bulgaria

Deyan Peev

Founding Engineer in Sofia, Bulgaria. Currently at 1club.

Elsewhere

© 2026 Deyan Peev