MERGE é uma única instrução que executa INSERT, UPDATE ou DELETE em uma só passada, conforme exista ou não uma linha correspondente na tabela de destino. O PostgreSQL não a tinha antes da versão 15, então todo mundo se apoiava em INSERT ... ON CONFLICT. Agora temos o MERGE de verdade, conforme o padrão SQL, e ele cobre casos que o ON CONFLICT simplesmente não consegue: sincronizar duas tabelas, apagar as linhas que ficaram para trás e aplicar ações diferentes sob condições diferentes.
Vamos ver como ele é montado e onde, de fato, fica a fronteira entre MERGE e ON CONFLICT.
Anatomia: destino, origem e ramos WHEN
MERGE recebe uma tabela de destino (MERGE INTO), uma origem (USING) e uma condição de junção (ON). Depois vêm o ramo WHEN MATCHED (a linha correspondeu no ON) e o ramo WHEN NOT MATCHED (presente na origem, ausente no destino).
Considere um esquema com uma tabela users e um lote de entrada users_staging.
MERGE INTO users AS u
USING users_staging AS s
ON u.id = s.id
WHEN MATCHED THEN
UPDATE SET email = s.email,
name = s.name,
updated_at = now()
WHEN NOT MATCHED THEN
INSERT (id, email, name, created_at)
VALUES (s.id, s.email, s.name, now());
Pontos-chave para internalizar:
- A origem pode ser uma tabela, uma subconsulta ou uma lista
VALUES.
- Os ramos são avaliados de cima para baixo; o primeiro que corresponder é disparado.
- Você não pode referenciar
EXCLUDED dentro de um ramo — essa é a sintaxe de ON CONFLICT e não existe no MERGE. Basta escrever s.email, s.name.
MERGE não retorna linhas em versões mais antigas; o RETURNING só chegou no PostgreSQL 17.
Ramos condicionais e DELETE
O verdadeiro poder do MERGE está nas condições AND extras em cada ramo, somadas ao fato de você poder ter vários ramos. É exatamente isso que o ON CONFLICT não consegue reproduzir.
MERGE INTO orders AS o
USING incoming_orders AS i
ON o.order_id = i.order_id
WHEN MATCHED AND i.status = 'cancelled' THEN
DELETE
WHEN MATCHED AND o.amount <> i.amount THEN
UPDATE SET amount = i.amount,
status = i.status,
updated_at = now()
WHEN NOT MATCHED THEN
INSERT (order_id, customer_id, amount, status)
VALUES (i.order_id, i.customer_id, i.amount, i.status);
Três resultados diferentes em uma única instrução: um pedido cancelado é apagado, um alterado é atualizado (somente quando o valor de fato difere) e um novo é inserido. Você também pode adicionar WHEN NOT MATCHED ... AND ... para filtrar quais inserções acontecem.
Jogadas úteis:
WHEN MATCHED AND ... THEN DO NOTHING — pular uma linha explicitamente.
WHEN NOT MATCHED BY SOURCE THEN DELETE — remover do destino o que está ausente na origem (PostgreSQL 17; perfeito para uma sincronização completa).
- A ordem dos ramos é a ordem de prioridade. Coloque as condições restritas acima das amplas.
Sincronização completa de duas tabelas
Uma tarefa clássica: alinhar uma tabela employees com uma exportação de um sistema de RH, hr_feed — incluir os recém-chegados, atualizar as mudanças e desligar os ausentes.
MERGE INTO employees AS e
USING hr_feed AS f
ON e.emp_id = f.emp_id
WHEN MATCHED AND (e.department, e.salary) IS DISTINCT FROM (f.department, f.salary) THEN
UPDATE SET department = f.department,
salary = f.salary
WHEN NOT MATCHED THEN
INSERT (emp_id, full_name, department, salary)
VALUES (f.emp_id, f.full_name, f.department, f.salary)
WHEN NOT MATCHED BY SOURCE THEN
UPDATE SET status = 'terminated';
IS DISTINCT FROM protege contra UPDATEs inúteis quando nada mudou (e compara NULL corretamente). NOT MATCHED BY SOURCE cobre o terceiro canto — linhas que existem apenas no destino.
O MySQL não tem um MERGE autônomo; usa-se INSERT ... ON DUPLICATE KEY UPDATE. O ClickHouse segue um caminho totalmente diferente: inserção mais a lógica de ReplacingMergeTree/engine, com a deduplicação adiada para uma mesclagem em segundo plano.
MERGE versus ON CONFLICT: qual escolher
Ambos conseguem fazer upsert, mas resolvem isso de formas diferentes.
ON CONFLICT é acionado por uma violação de índice único ou restrição. Sem índice adequado, não há upsert.
MERGE corresponde por uma condição ON arbitrária. Um índice não é obrigatório (embora ajude no desempenho).
ON CONFLICT é atômico frente a inserções concorrentes: sob uma condição de corrida, ele captura o conflito de forma confiável. MERGE sob alta concorrência pode falhar com uma violação de unicidade — ele não faz nenhuma mágica de skip-locked.
MERGE pode fazer DELETE e executar vários ramos distintos; ON CONFLICT só faz "inserir ou atualizar uma linha".
Uma regra prática:
INSERT INTO page_views (page_id, views)
VALUES (42, 1)
ON CONFLICT (page_id)
DO UPDATE SET views = page_views.views + 1;
Para um upsert concorrente e muito frequente em uma única chave, ON CONFLICT é mais simples e seguro. Recorra ao MERGE quando precisar da lógica de "vários ramos + DELETE + sincronização" — ou seja, processamento em lote e ETL.
Pegadinha. MERGE não te protege das condições de corrida por conta própria. Se duas sessões fizerem merge da mesma chave ainda inexistente ao mesmo tempo, ambas tomam o ramo NOT MATCHED e uma esbarra em um unique_violation. Para cargas de trabalho concorrentes, envolva-o em uma nova tentativa ou use ON CONFLICT. E lembre-se: RETURNING a partir do MERGE só está disponível a partir do PostgreSQL 17.
MERGEé uma única instrução que executaINSERT,UPDATEouDELETEem uma só passada, conforme exista ou não uma linha correspondente na tabela de destino. O PostgreSQL não a tinha antes da versão 15, então todo mundo se apoiava emINSERT ... ON CONFLICT. Agora temos oMERGEde verdade, conforme o padrão SQL, e ele cobre casos que oON CONFLICTsimplesmente não consegue: sincronizar duas tabelas, apagar as linhas que ficaram para trás e aplicar ações diferentes sob condições diferentes.Vamos ver como ele é montado e onde, de fato, fica a fronteira entre
MERGEeON CONFLICT.Anatomia: destino, origem e ramos WHEN
MERGErecebe uma tabela de destino (MERGE INTO), uma origem (USING) e uma condição de junção (ON). Depois vêm o ramoWHEN MATCHED(a linha correspondeu noON) e o ramoWHEN NOT MATCHED(presente na origem, ausente no destino).Considere um esquema com uma tabela
userse um lote de entradausers_staging.MERGE INTO users AS u USING users_staging AS s ON u.id = s.id WHEN MATCHED THEN UPDATE SET email = s.email, name = s.name, updated_at = now() WHEN NOT MATCHED THEN INSERT (id, email, name, created_at) VALUES (s.id, s.email, s.name, now());Pontos-chave para internalizar:
VALUES.EXCLUDEDdentro de um ramo — essa é a sintaxe deON CONFLICTe não existe noMERGE. Basta escrevers.email,s.name.MERGEnão retorna linhas em versões mais antigas; oRETURNINGsó chegou no PostgreSQL 17.Ramos condicionais e DELETE
O verdadeiro poder do
MERGEestá nas condiçõesANDextras em cada ramo, somadas ao fato de você poder ter vários ramos. É exatamente isso que oON CONFLICTnão consegue reproduzir.MERGE INTO orders AS o USING incoming_orders AS i ON o.order_id = i.order_id WHEN MATCHED AND i.status = 'cancelled' THEN DELETE WHEN MATCHED AND o.amount <> i.amount THEN UPDATE SET amount = i.amount, status = i.status, updated_at = now() WHEN NOT MATCHED THEN INSERT (order_id, customer_id, amount, status) VALUES (i.order_id, i.customer_id, i.amount, i.status);Três resultados diferentes em uma única instrução: um pedido cancelado é apagado, um alterado é atualizado (somente quando o valor de fato difere) e um novo é inserido. Você também pode adicionar
WHEN NOT MATCHED ... AND ...para filtrar quais inserções acontecem.Jogadas úteis:
WHEN MATCHED AND ... THEN DO NOTHING— pular uma linha explicitamente.WHEN NOT MATCHED BY SOURCE THEN DELETE— remover do destino o que está ausente na origem (PostgreSQL 17; perfeito para uma sincronização completa).Sincronização completa de duas tabelas
Uma tarefa clássica: alinhar uma tabela
employeescom uma exportação de um sistema de RH,hr_feed— incluir os recém-chegados, atualizar as mudanças e desligar os ausentes.MERGE INTO employees AS e USING hr_feed AS f ON e.emp_id = f.emp_id WHEN MATCHED AND (e.department, e.salary) IS DISTINCT FROM (f.department, f.salary) THEN UPDATE SET department = f.department, salary = f.salary WHEN NOT MATCHED THEN INSERT (emp_id, full_name, department, salary) VALUES (f.emp_id, f.full_name, f.department, f.salary) WHEN NOT MATCHED BY SOURCE THEN UPDATE SET status = 'terminated';IS DISTINCT FROMprotege contraUPDATEs inúteis quando nada mudou (e comparaNULLcorretamente).NOT MATCHED BY SOURCEcobre o terceiro canto — linhas que existem apenas no destino.O MySQL não tem um
MERGEautônomo; usa-seINSERT ... ON DUPLICATE KEY UPDATE. O ClickHouse segue um caminho totalmente diferente: inserção mais a lógica deReplacingMergeTree/engine, com a deduplicação adiada para uma mesclagem em segundo plano.MERGE versus ON CONFLICT: qual escolher
Ambos conseguem fazer upsert, mas resolvem isso de formas diferentes.
ON CONFLICTé acionado por uma violação de índice único ou restrição. Sem índice adequado, não há upsert.MERGEcorresponde por uma condiçãoONarbitrária. Um índice não é obrigatório (embora ajude no desempenho).ON CONFLICTé atômico frente a inserções concorrentes: sob uma condição de corrida, ele captura o conflito de forma confiável.MERGEsob alta concorrência pode falhar com uma violação de unicidade — ele não faz nenhuma mágica de skip-locked.MERGEpode fazerDELETEe executar vários ramos distintos;ON CONFLICTsó faz "inserir ou atualizar uma linha".Uma regra prática:
-- Simple counter / idempotent insert - reach for ON CONFLICT INSERT INTO page_views (page_id, views) VALUES (42, 1) ON CONFLICT (page_id) DO UPDATE SET views = page_views.views + 1;Para um upsert concorrente e muito frequente em uma única chave,
ON CONFLICTé mais simples e seguro. Recorra aoMERGEquando precisar da lógica de "vários ramos + DELETE + sincronização" — ou seja, processamento em lote e ETL.Pegadinha.
MERGEnão te protege das condições de corrida por conta própria. Se duas sessões fizerem merge da mesma chave ainda inexistente ao mesmo tempo, ambas tomam o ramoNOT MATCHEDe uma esbarra em umunique_violation. Para cargas de trabalho concorrentes, envolva-o em uma nova tentativa ou useON CONFLICT. E lembre-se:RETURNINGa partir doMERGEsó está disponível a partir do PostgreSQL 17.