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);
Здесь три сценария:
- Если заказ уже есть и во входящих данных он отменён — удаляем.
- Если заказ уже есть и сумма изменилась — обновляем.
- Если заказа ещё нет — вставляем.
Почему ветка с 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.
Что может произойти:
- Первый процесс проверил таблицу и увидел, что строки ещё нет.
- Второй процесс тоже проверил таблицу и увидел, что строки ещё нет.
- Оба пошли в ветку
WHEN NOT MATCHED.
- Оба попытались вставить строку.
- Один вставил успешно.
- Второй получил ошибку уникальности.
То есть 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 мощный, но именно поэтому его стоит писать аккуратно.
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());На человеческом языке этот запрос читается так:
Анатомия 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 uusers— это целевая таблица. Именно её мы будем менять: вставлять в неё строки, обновлять или удалять.Алиас
uнужен, чтобы дальше удобно ссылаться на колонки целевой таблицы:Источник данных: USING
USING users_staging AS susers_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.nameWHEN MATCHEDсрабатывает, если строка из источника нашла пару в целевой таблице по условиюON.Например:
Если строка совпала, её можно обновить:
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срабатывает, если строка из источника не нашла пару в целевой таблице.Например:
То есть в источнике строка есть, а в основной таблице её ещё нет. Обычно в такой ситуации делают
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мы используем алиас источника:Например:
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);Здесь три сценария:
Почему ветка с
cancelledстоит первой? Потому что это более конкретное правило. Если поставить широкую ветку обновления выше, до удаления дело может не дойти.Условия внутри WHEN
Ветки можно уточнять через
AND.Например:
WHEN MATCHED AND o.amount <> i.amount THEN UPDATE SET amount = i.amountЭто значит:
Можно фильтровать и вставки:
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.Что может произойти:
WHEN NOT MATCHED.То есть
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.Главное правило выбора такое:
ON CONFLICT;MERGE.И не забывайте про индексы, порядок веток и проверку плана выполнения.
MERGEмощный, но именно поэтому его стоит писать аккуратно.