sqlpostgresqlmergeupsert

MERGE no PostgreSQL 15+: MATCHED / NOT MATCHED, upserts e sincronização de tabelas

Um olhar prático sobre o MERGE no PostgreSQL 15+: os ramos MATCHED e NOT MATCHED, os padrões de upsert e sincronização, e quando recorrer a ele em vez de ON CONFLICT.

3 min de leituraReferencesql · postgresql · merge · upsert · etl

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:

-- 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 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.

Pratique com exercícios reais

Resolva exercícios no treinador de SQL com correção instantânea e dicas.

Abrir o treinador