postgresql
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
MERGEat a view does not work. - There is no branch for "rows in the target that the source never mentioned".
You can
DELETEa 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.
Written by
Deyan Peev
Founding Engineer · Sofia, Bulgaria


