sqlpostgresqlmergeupsert

MERGE в SQL: INSERT, UPDATE и DELETE в одном операторе

Разбираем оператор MERGE в PostgreSQL 15+: ветки MATCHED и NOT MATCHED, паттерны upsert и синхронизации, и когда он лучше старого доброго ON CONFLICT.

11 мин чтенияСправочникsql · postgresql · merge · upsert · etl

MERGE — это оператор SQL, который умеет сравнить две таблицы и выполнить разные действия в зависимости от результата.

Если строка уже есть в целевой таблице — её можно обновить.
Если строки ещё нет — её можно вставить.
Если строку нужно убрать — её можно удалить.

То есть MERGE помогает решить задачу:

Вот у нас есть основная таблица и свежая пачка данных. Нужно аккуратно привести основную таблицу к новому состоянию.

До PostgreSQL 15 разработчики часто использовали INSERT ... ON CONFLICT, но это не одно и то же. ON CONFLICT хорошо подходит для простого сценария «вставь или обнови». А MERGE шире: он умеет несколько условий, несколько веток, обновления, вставки и удаления в одном запросе.

Разберёмся спокойно: из чего состоит MERGE, когда он удобен, чем отличается от ON CONFLICT и какие ловушки важно знать новичку.

Зачем нужен MERGE

Представьте интернет-магазин.

У вас есть основная таблица пользователей:

CREATE TABLE users (
  id bigint PRIMARY KEY,
  email text NOT NULL,
  name text NOT NULL,
  created_at timestamp NOT NULL,
  updated_at timestamp
);

И есть временная таблица со свежей выгрузкой:

CREATE TABLE users_staging (
  id bigint,
  email text,
  name text
);

В users_staging пришли данные из внешней системы. Теперь нужно:

  • если пользователь уже есть в users, обновить его email и имя;
  • если пользователя ещё нет, добавить его;
  • сделать всё одним понятным запросом.

Для такой задачи и подходит MERGE.

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());

На человеческом языке этот запрос читается так:

Возьми таблицу users, сравни её с users_staging по id. Если пользователь найден — обнови. Если не найден — вставь.

Анатомия MERGE

У MERGE есть несколько главных частей.

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
WHEN NOT MATCHED THEN
  INSERT (id, email, name)
  VALUES (s.id, s.email, s.name);

Разберём по кусочкам.

Целевая таблица: MERGE INTO

MERGE INTO users AS u

users — это целевая таблица. Именно её мы будем менять: вставлять в неё строки, обновлять или удалять.

Алиас u нужен, чтобы дальше удобно ссылаться на колонки целевой таблицы:

u.id
u.email
u.name

Источник данных: USING

USING users_staging AS s

users_staging — это источник новых данных.

Источник может быть:

  • таблицей;
  • временной таблицей;
  • подзапросом;
  • набором строк через VALUES.

Например, источник можно задать прямо в запросе:

MERGE INTO users AS u
USING (
  VALUES
    (1, 'anna@example.com', 'Anna'),
    (2, 'boris@example.com', 'Boris')
) AS s(id, email, name)
  ON u.id = s.id
WHEN MATCHED THEN
  UPDATE SET
    email = s.email,
    name = s.name
WHEN NOT MATCHED THEN
  INSERT (id, email, name, created_at)
  VALUES (s.id, s.email, s.name, now());

Так удобно делать небольшие точечные синхронизации.

Условие сравнения: ON

ON u.id = s.id

Это условие говорит базе, как понять, что строка из источника соответствует строке из целевой таблицы.

Обычно сравнивают по ключу:

ON u.id = s.id

или по бизнес-ключу:

ON u.email = s.email

Важно: ON в MERGE — это не то же самое, что конфликт уникального индекса в ON CONFLICT. В MERGE вы сами задаёте условие сравнения.

Ветка WHEN MATCHED

WHEN MATCHED THEN
  UPDATE SET
    email = s.email,
    name = s.name

WHEN MATCHED срабатывает, если строка из источника нашла пару в целевой таблице по условию ON.

Например:

source id target id result
10 10 matched

Если строка совпала, её можно обновить:

WHEN MATCHED THEN
  UPDATE SET
    email = s.email,
    name = s.name

Или удалить:

WHEN MATCHED THEN
  DELETE

Или явно ничего не делать:

WHEN MATCHED THEN
  DO NOTHING

Ветка WHEN NOT MATCHED

WHEN NOT MATCHED THEN
  INSERT (id, email, name, created_at)
  VALUES (s.id, s.email, s.name, now())

WHEN NOT MATCHED срабатывает, если строка из источника не нашла пару в целевой таблице.

Например:

source id target id result
15 NULL not matched

То есть в источнике строка есть, а в основной таблице её ещё нет. Обычно в такой ситуации делают INSERT.

В MERGE нет EXCLUDED

Если вы уже знаете INSERT ... ON CONFLICT, то могли встречать специальное имя EXCLUDED.

Например:

INSERT INTO users (id, email, name)
VALUES (1, 'anna@example.com', 'Anna')
ON CONFLICT (id)
DO UPDATE SET
  email = EXCLUDED.email,
  name = EXCLUDED.name;

В MERGE такого имени нет.

Вместо EXCLUDED.email мы используем алиас источника:

s.email

Например:

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;

Запомните простое правило:

  • в ON CONFLICT новые значения часто берут из EXCLUDED;
  • в MERGE новые значения берут из источника, например из s.

Порядок веток важен

У MERGE может быть несколько веток WHEN.

Они проверяются сверху вниз. Срабатывает первая подходящая ветка.

Это похоже на CASE: более узкие условия лучше ставить выше широких.

Например:

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);

Здесь три сценария:

  1. Если заказ уже есть и во входящих данных он отменён — удаляем.
  2. Если заказ уже есть и сумма изменилась — обновляем.
  3. Если заказа ещё нет — вставляем.

Почему ветка с cancelled стоит первой? Потому что это более конкретное правило. Если поставить широкую ветку обновления выше, до удаления дело может не дойти.

Условия внутри WHEN

Ветки можно уточнять через AND.

Например:

WHEN MATCHED AND o.amount <> i.amount THEN
  UPDATE SET
    amount = i.amount

Это значит:

Если строка совпала по ON и сумма изменилась, тогда обнови.

Можно фильтровать и вставки:

WHEN NOT MATCHED AND s.email IS NOT NULL THEN
  INSERT (id, email, name)
  VALUES (s.id, s.email, s.name)

Так мы вставим только строки, у которых есть email.

Если условие не подходит, можно явно ничего не делать:

WHEN MATCHED AND s.email IS NULL THEN
  DO NOTHING

Это делает запрос более читаемым: видно, что такая ситуация предусмотрена специально, а не забыта случайно.

Пример с заказами: обновить, удалить или вставить

Допустим, у нас есть основная таблица заказов:

CREATE TABLE orders (
  order_id bigint PRIMARY KEY,
  customer_id bigint NOT NULL,
  amount numeric(10, 2) NOT NULL,
  status text NOT NULL,
  updated_at timestamp
);

И таблица входящих заказов:

CREATE TABLE incoming_orders (
  order_id bigint,
  customer_id bigint,
  amount numeric(10, 2),
  status text
);

Нужно обработать свежую пачку:

  • отменённые заказы удалить;
  • изменившиеся заказы обновить;
  • новые заказы вставить.
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);

Такой запрос удобно читать как набор бизнес-правил.

Это сильная сторона MERGE: он позволяет держать логику синхронизации в одном месте, а не размазывать её по нескольким отдельным запросам.

Почему не всегда хватает ON CONFLICT

INSERT ... ON CONFLICT решает более узкую задачу:

Попробуй вставить строку. Если нарушится уникальность, обнови существующую.

Пример:

INSERT INTO page_views (page_id, views)
VALUES (42, 1)
ON CONFLICT (page_id)
DO UPDATE SET
  views = page_views.views + 1;

Это прекрасный вариант для счётчика просмотров. Есть страница — увеличили счётчик. Нет страницы — вставили первую запись.

Но ON CONFLICT не умеет удобно выразить сложную синхронизацию:

  • разные действия для разных условий;
  • удаление строк;
  • сравнение с произвольным источником;
  • сценарий «обнови одно, вставь другое, третье пропусти».

Для этого удобнее MERGE.

Полная синхронизация таблиц

Одна из лучших задач для MERGE — синхронизация основной таблицы с внешней выгрузкой.

Допустим, есть таблица сотрудников:

CREATE TABLE employees (
  emp_id bigint PRIMARY KEY,
  full_name text NOT NULL,
  department text,
  salary numeric(10, 2),
  status text NOT NULL
);

И есть свежая выгрузка из HR-системы:

CREATE TABLE hr_feed (
  emp_id bigint,
  full_name text,
  department text,
  salary numeric(10, 2)
);

Нужно:

  • обновить сотрудников, у которых изменился отдел или зарплата;
  • добавить новых сотрудников;
  • пометить уволенными тех, кого больше нет в выгрузке.

В PostgreSQL 17 и новее можно использовать ветку WHEN NOT MATCHED BY SOURCE.

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, status)
  VALUES (f.emp_id, f.full_name, f.department, f.salary, 'active')
WHEN NOT MATCHED BY SOURCE THEN
  UPDATE SET
    status = 'terminated';

Здесь есть важная деталь:

(e.department, e.salary) IS DISTINCT FROM (f.department, f.salary)

Почему не просто так?

e.department <> f.department

Потому что обычное сравнение плохо работает с NULL.

Например:

NULL <> 'Sales'

не даёт обычное true. В SQL результатом будет неизвестность.

А IS DISTINCT FROM сравнивает значения аккуратно:

  • два одинаковых значения — не отличаются;
  • NULL и NULL — не отличаются;
  • NULL и обычное значение — отличаются.

Для синхронизации данных это очень удобно.

Зачем избегать лишних UPDATE

В примере выше мы обновляем сотрудника только если отдел или зарплата реально изменились:

WHEN MATCHED AND (e.department, e.salary) IS DISTINCT FROM (f.department, f.salary) THEN
  UPDATE SET
    department = f.department,
    salary = f.salary

Можно было бы обновлять каждую совпавшую строку:

WHEN MATCHED THEN
  UPDATE SET
    department = f.department,
    salary = f.salary

Но это хуже.

Лишние UPDATE:

  • создают дополнительную работу для базы;
  • меняют служебные версии строк;
  • могут раздувать таблицу;
  • могут запускать триггеры;
  • могут обновлять индексы;
  • могут портить смысл поля updated_at, если оно есть.

Поэтому хороший MERGE не просто «обновляет всё подряд», а сначала проверяет, действительно ли данные изменились.

Что значит WHEN NOT MATCHED BY SOURCE

Обычная ветка:

WHEN NOT MATCHED THEN

означает:

Строка есть в источнике, но её нет в целевой таблице.

То есть это хороший случай для вставки.

А ветка:

WHEN NOT MATCHED BY SOURCE THEN

означает обратное:

Строка есть в целевой таблице, но её больше нет в источнике.

Это полезно при полной синхронизации.

Например, если сотрудник исчез из выгрузки HR-системы, можно не удалять его физически, а пометить уволенным:

WHEN NOT MATCHED BY SOURCE THEN
  UPDATE SET
    status = 'terminated'

Или удалить строку:

WHEN NOT MATCHED BY SOURCE THEN
  DELETE

Но с удалением нужно быть осторожным. Для бизнес-данных часто безопаснее ставить статус, чем физически удалять историю.

MERGE и RETURNING

В PostgreSQL 15 появился сам оператор MERGE.

Но возможность вернуть изменённые строки через RETURNING появилась только в PostgreSQL 17.

В PostgreSQL 17 и новее можно писать так:

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
WHEN NOT MATCHED THEN
  INSERT (id, email, name, created_at)
  VALUES (s.id, s.email, s.name, now())
RETURNING u.id, u.email, u.name;

Если вы работаете с PostgreSQL 15 или 16, MERGE есть, но RETURNING для него недоступен.

Это важно учитывать при написании кода приложения: иногда после MERGE придётся отдельным запросом прочитать результат.

MERGE против ON CONFLICT

MERGE и ON CONFLICT часто сравнивают, потому что оба могут делать upsert.

Upsert — это логика «вставь, а если уже есть — обнови».

Но устроены они по-разному.

ON CONFLICT

ON CONFLICT работает через уникальный индекс или уникальное ограничение.

Например:

CREATE TABLE page_views (
  page_id bigint PRIMARY KEY,
  views bigint NOT NULL
);

Тогда можно написать:

INSERT INTO page_views (page_id, views)
VALUES (42, 1)
ON CONFLICT (page_id)
DO UPDATE SET
  views = page_views.views + 1;

Здесь конфликт возникает по page_id, потому что это первичный ключ.

Если подходящего уникального индекса нет, ON CONFLICT не сможет понять, какой конфликт ловить.

MERGE

MERGE работает через условие ON.

MERGE INTO users AS u
USING users_staging AS s
  ON u.id = s.id
WHEN MATCHED THEN
  UPDATE SET
    email = s.email
WHEN NOT MATCHED THEN
  INSERT (id, email, name, created_at)
  VALUES (s.id, s.email, s.name, now());

Индекс для MERGE не обязателен с точки зрения синтаксиса. Но для скорости он почти всегда желателен.

Если вы сравниваете по id, индекс по id поможет базе быстро находить совпадения.

Что выбрать

Для простого конкурентного upsert по одному ключу чаще лучше использовать ON CONFLICT.

Например:

INSERT INTO page_views (page_id, views)
VALUES (42, 1)
ON CONFLICT (page_id)
DO UPDATE SET
  views = page_views.views + 1;

Это коротко, понятно и хорошо работает при параллельных вставках.

MERGE лучше выбирать для пакетной логики:

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
WHEN NOT MATCHED THEN
  INSERT (order_id, customer_id, amount, status)
  VALUES (i.order_id, i.customer_id, i.amount, i.status);

Здесь уже не просто «вставить или обновить». Здесь полноценная обработка потока данных.

Важная ловушка: конкуренция

MERGE не делает магии при высокой конкуренции.

Представьте два процесса. Оба одновременно пытаются выполнить MERGE для одного и того же нового id.

Что может произойти:

  1. Первый процесс проверил таблицу и увидел, что строки ещё нет.
  2. Второй процесс тоже проверил таблицу и увидел, что строки ещё нет.
  3. Оба пошли в ветку WHEN NOT MATCHED.
  4. Оба попытались вставить строку.
  5. Один вставил успешно.
  6. Второй получил ошибку уникальности.

То есть MERGE может упасть с ошибкой unique_violation, если два сеанса одновременно вставляют один и тот же ключ.

Для горячих конкурентных сценариев лучше использовать INSERT ... ON CONFLICT, потому что он специально заточен под такую гонку.

А если MERGE всё-таки нужен, обычно добавляют повтор операции при ошибке, то есть retry на уровне приложения или фонового процесса.

Индексы для MERGE

Хотя MERGE может работать без индекса, на больших таблицах это почти всегда плохая идея.

Если вы пишете:

MERGE INTO users AS u
USING users_staging AS s
  ON u.id = s.id

то на целевой таблице должен быть индекс по id.

Обычно он уже есть, если id — первичный ключ:

CREATE TABLE users (
  id bigint PRIMARY KEY,
  email text NOT NULL,
  name text NOT NULL
);

Если сравнение идёт по email, нужен индекс по email:

CREATE INDEX idx_users_email
  ON users (email);

Если сравнение составное, индекс тоже часто делают составным:

CREATE INDEX idx_orders_customer_external
  ON orders (customer_id, external_order_id);

Идея простая: MERGE должен быстро находить, есть ли в целевой таблице соответствующая строка. Если для этого приходится каждый раз просматривать всю таблицу, операция станет тяжёлой.

Проверяйте план выполнения

После написания MERGE полезно проверить, как база собирается его выполнять.

В PostgreSQL можно использовать EXPLAIN.

EXPLAIN
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
WHEN NOT MATCHED THEN
  INSERT (id, email, name, created_at)
  VALUES (s.id, s.email, s.name, now());

Для реального выполнения с замерами используют EXPLAIN ANALYZE, но с ним нужно быть осторожным: запрос действительно выполнится и изменит данные.

На учебной или тестовой базе это нормально. На боевой базе так делать без понимания последствий нельзя.

MERGE в MySQL

В MySQL отдельного оператора MERGE в стиле PostgreSQL нет.

Для простого upsert обычно используют:

INSERT INTO users (id, email, name)
VALUES (1, 'anna@example.com', 'Anna')
ON DUPLICATE KEY UPDATE
  email = VALUES(email),
  name = VALUES(name);

Это похоже на PostgreSQL ON CONFLICT, но не равно полноценному MERGE.

Если нужна сложная синхронизация с удалением, несколькими условиями и разными ветками, в MySQL часто приходится писать несколько запросов:

  • отдельно INSERT;
  • отдельно UPDATE;
  • отдельно DELETE;
  • иногда использовать временные таблицы.

MERGE в ClickHouse

ClickHouse устроен иначе. Это колоночная аналитическая СУБД, и классический построчный MERGE из OLTP-баз туда переносится плохо.

Вместо привычного MERGE там часто используют другой подход:

  • вставляют новые версии строк;
  • применяют движки вроде ReplacingMergeTree;
  • используют ключи сортировки;
  • ждут фоновых слияний;
  • строят витрины или материализованные представления.

Главная идея: в ClickHouse данные обычно не «обновляют построчно» так же, как в PostgreSQL. Там чаще проектируют таблицу так, чтобы новые версии данных дописывались, а финальное состояние собиралось особенностями движка или запросом.

Поэтому при переносе логики из PostgreSQL в ClickHouse не стоит искать полный аналог MERGE один к одному. Лучше пересмотреть модель хранения.

Типичные ошибки с MERGE

Ошибка 1. Использовать MERGE для простого счётчика

Если задача такая:

INSERT INTO page_views (page_id, views)
VALUES (42, 1)
ON CONFLICT (page_id)
DO UPDATE SET
  views = page_views.views + 1;

то ON CONFLICT обычно лучше.

Не стоит брать MERGE только потому, что он выглядит мощнее. Чем проще инструмент подходит под задачу, тем лучше.

Ошибка 2. Забыть про порядок веток

Плохо:

MERGE INTO orders AS o
USING incoming_orders AS i
  ON o.order_id = i.order_id
WHEN MATCHED THEN
  UPDATE SET
    amount = i.amount,
    status = i.status
WHEN MATCHED AND i.status = 'cancelled' THEN
  DELETE;

Ветка WHEN MATCHED THEN слишком широкая. Она перехватит все совпавшие строки, и до более узкой ветки с cancelled дело может не дойти.

Лучше так:

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 THEN
  UPDATE SET
    amount = i.amount,
    status = i.status;

Сначала частные случаи, потом общие.

Ошибка 3. Обновлять строки без необходимости

Плохо:

WHEN MATCHED THEN
  UPDATE SET
    department = f.department,
    salary = f.salary

Лучше обновлять только изменившиеся строки:

WHEN MATCHED AND (e.department, e.salary) IS DISTINCT FROM (f.department, f.salary) THEN
  UPDATE SET
    department = f.department,
    salary = f.salary

Так база делает меньше лишней работы.

Ошибка 4. Ждать, что MERGE сам решит гонки

MERGE не заменяет ON CONFLICT для горячих конкурентных вставок.

Если много процессов одновременно вставляют один и тот же ключ, ON CONFLICT часто безопаснее и проще.

Ошибка 5. Не поставить индекс под ON

Плохо писать большой MERGE по условию:

ON u.email = s.email

и не иметь индекса по email.

Для больших таблиц это может привести к тяжёлому плану и долгому выполнению.

Короткий пример для запоминания

Самый простой шаблон MERGE выглядит так:

MERGE INTO target_table AS t
USING source_table AS s
  ON t.id = s.id
WHEN MATCHED THEN
  UPDATE SET
    value = s.value
WHEN NOT MATCHED THEN
  INSERT (id, value)
  VALUES (s.id, s.value);

Расшифровка:

  • target_table — таблица, которую меняем;
  • source_table — таблица, откуда берём свежие данные;
  • ON t.id = s.id — правило совпадения строк;
  • WHEN MATCHED — что делать, если строка уже есть;
  • WHEN NOT MATCHED — что делать, если строки ещё нет.

Что важно запомнить

MERGE — это оператор для синхронизации данных. Он сравнивает целевую таблицу с источником и выполняет разные действия в зависимости от результата.

Базовая форма выглядит так:

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
WHEN NOT MATCHED THEN
  INSERT (id, email, name, created_at)
  VALUES (s.id, s.email, s.name, now());

WHEN MATCHED срабатывает, когда строка найдена в целевой таблице.

WHEN NOT MATCHED срабатывает, когда строка есть в источнике, но её ещё нет в целевой таблице.

WHEN NOT MATCHED BY SOURCE полезен для полной синхронизации: он находит строки, которые есть в цели, но отсутствуют в источнике.

MERGE сильнее, чем простой upsert: он умеет несколько веток, условия, UPDATE, INSERT, DELETE и DO NOTHING.

Но для простого конкурентного сценария «вставить или обновить по уникальному ключу» часто лучше использовать INSERT ... ON CONFLICT.

Главное правило выбора такое:

  • нужен простой upsert по уникальному ключу — берите ON CONFLICT;
  • нужна синхронизация таблиц, несколько условий, удаление или сложная пакетная обработка — берите MERGE.

И не забывайте про индексы, порядок веток и проверку плана выполнения. MERGE мощный, но именно поэтому его стоит писать аккуратно.

Закрепи на практике

Решай задачи в SQL-тренажёре с мгновенной проверкой и подсказками.

Открыть тренажёр