Poznaj MERGE w PostgreSQL: składnia, import 500k rekordów w sekundy, porównanie z UPSERT, wzorce ETL i hurtownie danych.
Czym jest MERGE i dlaczego zmienia zasady gry?
Wyobraź sobie, że co noc dostajesz plik CSV z 500 000 pozycjami stanów magazynowych od dostawcy dropshippingowego. Część produktów już masz w bazie, część jest nowa, a kilka zostało wycofanych. Tradycyjne podejście wymaga trzech osobnych operacji: INSERT dla nowych, UPDATE dla istniejących i DELETE dla wycofanych.
MERGE zamyka to w jedno atomowe zapytanie SQL. Jedno zapytanie – trzy operacje – pełna spójność danych.
Polecenie MERGE pojawiło się w standardzie SQL:2003, a do PostgreSQL trafiło w wersji 15 (październik 2022). W wersji 17 zyskało klauzulę RETURNING i WHEN NOT MATCHED BY SOURCE, co czyni je jednym z najpotężniejszych narzędzi do synchronizacji danych.
Pełna składnia MERGE
Zanim przejdziemy do praktycznych przykładów, zobaczmy pełną składnię polecenia. Nie musisz zapamiętywać każdego szczegółu – wracaj tutaj jak do ściągawki.
[ WITH cte_query [, ...] ]
MERGE INTO target_table [ [ AS ] alias ]
USING source_table_or_query ON join_condition
WHEN MATCHED [ AND condition ] THEN
UPDATE SET column1 = value1 [, ...]
-- lub DELETE
-- lub DO NOTHING
WHEN NOT MATCHED [ BY TARGET ] [ AND condition ] THEN
INSERT (column1 [, ...])
VALUES (value1 [, ...])
-- lub DO NOTHING
WHEN NOT MATCHED BY SOURCE [ AND condition ] THEN
UPDATE SET column1 = value1 [, ...]
-- lub DELETE
-- lub DO NOTHING
[ RETURNING * | expression [, ...] ];Trzy klauzule WHEN – serce MERGE
Każdy wiersz kandydujący przechodzi przez klauzule WHEN od góry do dołu. Wykonywana jest tylko pierwsza pasująca klauzula – jak w instrukcji switch/case z automatycznym break.
| Klauzula | Kiedy się odpala | Dozwolone akcje |
|---|---|---|
WHEN MATCHED | Wiersz istnieje w źródle i w celu | UPDATE, DELETE, DO NOTHING |
WHEN NOT MATCHED [BY TARGET] | Wiersz istnieje tylko w źródle | INSERT, DO NOTHING |
WHEN NOT MATCHED BY SOURCE | Wiersz istnieje tylko w celu | UPDATE, DELETE, DO NOTHING |
WHEN NOT MATCHED BY SOURCE jest dostępna od PostgreSQL 17 i wymaga, aby join_condition wspierał FULL JOIN.
Przykład 1: Synchronizacja tabeli klientów
Zacznijmy od prostego scenariusza. Masz tabelę klientów i codziennie dostajesz aktualizacje z systemu CRM. Nowi klienci mają być dodani, a istniejący – zaktualizowani.
-- Tabela docelowa: nasi klienci
CREATE TABLE customers (
customer_id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT,
city TEXT,
updated_at TIMESTAMP DEFAULT now()
);
-- Dane początkowe
INSERT INTO customers (customer_id, name, email, city) VALUES
(1, 'Jan Kowalski', 'jan@example.com', 'Lublin'),
(2, 'Anna Nowak', 'anna@example.com', 'Warszawa'),
(3, 'Piotr Wiśniewski','piotr@example.com', 'Kraków');
-- Tabela źródłowa: import z CRM
CREATE TEMP TABLE crm_import (
customer_id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT,
city TEXT
);
INSERT INTO crm_import (customer_id, name, email, city) VALUES
(2, 'Anna Nowak-Kowalska', 'anna.nk@example.com', 'Warszawa'),
(4, 'Maria Zielińska', 'maria@example.com', 'Lublin'),
(5, 'Tomek Lewandowski', 'tomek@example.com', 'Gdańsk');MERGE INTO customers c
USING crm_import i ON c.customer_id = i.customer_id
WHEN MATCHED AND (c.name != i.name OR c.email != i.email OR c.city != i.city) THEN
UPDATE SET
name = i.name,
email = i.email,
city = i.city,
updated_at = now()
WHEN NOT MATCHED THEN
INSERT (customer_id, name, email, city)
VALUES (i.customer_id, i.name, i.email, i.city);
-- Wynik: MERGE 3
-- Anna zaktualizowana, Maria i Tomek dodani
-- Jan i Piotr nieobjęci (nie ma ich w imporcie)Przykład 2: Import stanów magazynowych (Dropshipping)
To jest scenariusz, w którym MERGE naprawdę błyszczy. Prowadzisz sklep dropshippingowy i co godzinę dostajesz plik CSV ze stanami magazynowymi od dostawcy.
Problem
Masz 500 000 produktów. Dostawca wysyła pełny plik – część cen się zmieniła, pojawiły się nowe produkty, kilka wycofano. Tradycyjne podejście:
- Rekord po rekordzie (WordPress WP All Import): Każdy wiersz = osobne zapytanie SELECT + UPDATE/INSERT. Przy 500k rekordów to 1 000 000+ zapytań → kilka godzin.
- DELETE ALL + INSERT: Szybkie, ale niszczy historię, indeksy się przebudowują i tracisz dane powiązane (opinie, SEO URL-e).
- MERGE: Jedno zapytanie, kilka sekund, pełna kontrola.
-- Tabela produktów w sklepie
CREATE TABLE products (
sku TEXT PRIMARY KEY,
name TEXT NOT NULL,
price NUMERIC(10,2) NOT NULL,
stock INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT true,
last_import TIMESTAMP,
created_at TIMESTAMP DEFAULT now()
);
-- Tabela tymczasowa z importem od dostawcy
CREATE TEMP TABLE supplier_feed (
sku TEXT PRIMARY KEY,
name TEXT NOT NULL,
price NUMERIC(10,2) NOT NULL,
stock INTEGER NOT NULL
);
-- Symulacja: ładowanie 500 000 rekordów z CSV
-- W prawdziwym scenariuszu:
-- COPY supplier_feed FROM '/data/supplier_feed.csv' WITH (FORMAT csv, HEADER);
INSERT INTO supplier_feed VALUES
('SKU-001', 'Laptop Dell XPS 15', 5499.00, 23),
('SKU-002', 'Monitor LG 27" 4K', 1899.00, 0),
('SKU-003', 'Klawiatura MX Keys', 449.00, 156),
('SKU-NEW', 'Nowa Mysz Logitech MX', 299.00, 500);MERGE INTO products p
USING supplier_feed f ON p.sku = f.sku
-- Produkt istnieje i dane się zmieniły → zaktualizuj
WHEN MATCHED AND (
p.price != f.price OR
p.stock != f.stock OR
p.name != f.name
) THEN
UPDATE SET
name = f.name,
price = f.price,
stock = f.stock,
is_active = (f.stock > 0),
last_import = now()
-- Produkt istnieje, ale nic się nie zmieniło → oznacz import
WHEN MATCHED THEN
UPDATE SET last_import = now()
-- Nowy produkt od dostawcy → dodaj
WHEN NOT MATCHED THEN
INSERT (sku, name, price, stock, is_active, last_import)
VALUES (f.sku, f.name, f.price, f.stock, f.stock > 0, now());
-- PostgreSQL 17+: możesz dodać trzecią klauzulę
-- WHEN NOT MATCHED BY SOURCE THEN
-- UPDATE SET is_active = false;
-- → dezaktywuje produkty, których dostawca już nie oferujePorównanie wydajności
| Metoda | Liczba zapytań SQL | Czas (500k rekordów) | Atomowość |
|---|---|---|---|
| WP All Import (rekord po rekordzie) | ~1 000 000 | 2-6 godzin | Brak |
| Pętla UPDATE/INSERT w aplikacji | ~500 000 | 30-90 minut | Brak |
| Batch UPDATE + INSERT | ~1 000 (batche po 500) | 1-5 minut | Częściowa |
| MERGE | 1 | 3-15 sekund | Pełna |
Różnica jest kolosalna. Jedno zapytanie MERGE z tabelą tymczasową wypełnioną przez COPY jest szybsze o rzędy wielkości, ponieważ:
- Brak round-tripów sieciowych (1 zapytanie vs 500 000)
- Planner optymalizuje JOIN jednorazowo
- Indeksy aktualizowane hurtowo
- Cała operacja w jednej transakcji – albo wszystko, albo nic
MERGE vs INSERT ... ON CONFLICT (UPSERT)
PostgreSQL od wersji 9.5 ma INSERT ... ON CONFLICT (popularnie zwany UPSERT). Kiedy użyć którego?
-- Klasyczny UPSERT (PostgreSQL 9.5+)
INSERT INTO products (sku, name, price, stock)
VALUES ('SKU-001', 'Laptop Dell XPS 15', 5499.00, 23)
ON CONFLICT (sku) DO UPDATE SET
name = EXCLUDED.name,
price = EXCLUDED.price,
stock = EXCLUDED.stock;
-- Prosty, sprawdzony
-- Tylko INSERT + UPDATE (brak DELETE)
-- Wymaga unikalnego constraintu/indeksu
-- Brak warunkowego DO NOTHING na MATCHEDKiedy co wybrać?
| Cecha | INSERT ... ON CONFLICT | MERGE |
|---|---|---|
| Dostępny od | PostgreSQL 9.5 | PostgreSQL 15 |
| Operacje | INSERT + UPDATE | INSERT + UPDATE + DELETE |
| Wymaga constraintu | Tak (UNIQUE/PK) | Nie – JOIN dowolny |
| Wiele klauzul WHEN | Nie | Tak (z warunkami AND) |
| RETURNING (PG 17+) | Tak | Tak + merge_action() |
| Bezpieczeństwo współbieżne | Gwarantowane | Możliwy unique violation |
| Wydajność bulk ops | Dobra | Lepsza o ~30% |
Zasada ogólna:
- Pojedyncze wstawianie z wysokim współbieżnym dostępem →
ON CONFLICT - Bulk synchronizacja, ETL, import plików →
MERGE - Potrzebujesz warunkowego DELETE →
MERGE(jedyna opcja)
MERGE w hurtowniach danych (ETL/ELT)
W świecie hurtowni danych MERGE to absolutna podstawa. Służy do implementacji wzorców takich jak Slowly Changing Dimensions (SCD).
SCD Type 1 – nadpisywanie historii
Najprostszy wzorzec: jeśli dane się zmieniły, po prostu je zaktualizuj. Nie przechowujesz historii zmian.
-- Wymiar: klienci w hurtowni danych
CREATE TABLE dim_customer (
customer_key SERIAL PRIMARY KEY,
customer_id INTEGER UNIQUE NOT NULL,
name TEXT NOT NULL,
email TEXT,
city TEXT,
segment TEXT,
loaded_at TIMESTAMP DEFAULT now(),
updated_at TIMESTAMP DEFAULT now()
);
-- Staging: dane ze źródła (ładowane codziennie przez ETL)
CREATE TEMP TABLE stg_customer (
customer_id INTEGER PRIMARY KEY,
name TEXT,
email TEXT,
city TEXT,
segment TEXT
);
-- MERGE – SCD Type 1: nadpisuj zmiany, dodawaj nowe
MERGE INTO dim_customer d
USING stg_customer s ON d.customer_id = s.customer_id
WHEN MATCHED AND (
d.name != s.name OR d.email != s.email OR
d.city != s.city OR d.segment != s.segment
) THEN
UPDATE SET
name = s.name,
email = s.email,
city = s.city,
segment = s.segment,
updated_at = now()
WHEN NOT MATCHED THEN
INSERT (customer_id, name, email, city, segment)
VALUES (s.customer_id, s.name, s.email, s.city, s.segment);SCD Type 2 – pełna historia zmian
Bardziej zaawansowany wzorzec: zamiast nadpisywać, zamykasz stary wiersz (ustawiasz valid_to) i wstawiasz nowy z aktualnymi danymi.
-- Wymiar z historią zmian
CREATE TABLE dim_customer_scd2 (
customer_key SERIAL PRIMARY KEY,
customer_id INTEGER NOT NULL,
name TEXT NOT NULL,
city TEXT,
is_current BOOLEAN DEFAULT true,
valid_from TIMESTAMP DEFAULT now(),
valid_to TIMESTAMP DEFAULT '9999-12-31'::TIMESTAMP
);
-- Krok 1: Zamknij stare wiersze, które się zmieniły
MERGE INTO dim_customer_scd2 d
USING stg_customer s ON d.customer_id = s.customer_id
AND d.is_current = true
WHEN MATCHED AND (d.name != s.name OR d.city != s.city) THEN
UPDATE SET
is_current = false,
valid_to = now();
-- Krok 2: Wstaw nowe wersje (nowi klienci + zmienieni)
INSERT INTO dim_customer_scd2 (customer_id, name, city)
SELECT s.customer_id, s.name, s.city
FROM stg_customer s
WHERE NOT EXISTS (
SELECT 1 FROM dim_customer_scd2 d
WHERE d.customer_id = s.customer_id
AND d.is_current = true
AND d.name = s.name
AND d.city = s.city
);Zaawansowane techniki
MERGE z CTE (Common Table Expressions)
Możesz użyć WITH do przygotowania danych źródłowych przed MERGE. To przydatne gdy dane wymagają transformacji.
WITH prepared_data AS (
SELECT DISTINCT ON (sku)
sku,
TRIM(UPPER(name)) AS name,
ROUND(price * 1.23, 2) AS price_with_vat,
GREATEST(stock, 0) AS stock
FROM supplier_feed
WHERE sku IS NOT NULL AND price > 0
ORDER BY sku, stock DESC
)
MERGE INTO products p
USING prepared_data d ON p.sku = d.sku
WHEN MATCHED AND (p.price != d.price_with_vat OR p.stock != d.stock) THEN
UPDATE SET
price = d.price_with_vat,
stock = d.stock,
is_active = (d.stock > 0),
last_import = now()
WHEN NOT MATCHED THEN
INSERT (sku, name, price, stock, is_active, last_import)
VALUES (d.sku, d.name, d.price_with_vat, d.stock, d.stock > 0, now());RETURNING z merge_action() (PostgreSQL 17+)
Od PostgreSQL 17 możesz użyć RETURNING z funkcją merge_action(), żeby dowiedzieć się, co dokładnie się stało z każdym wierszem.
-- PostgreSQL 17+
MERGE INTO products p
USING supplier_feed f ON p.sku = f.sku
WHEN MATCHED AND (p.price != f.price) THEN
UPDATE SET price = f.price, last_import = now()
WHEN NOT MATCHED THEN
INSERT (sku, name, price, stock)
VALUES (f.sku, f.name, f.price, f.stock)
RETURNING
merge_action() AS action,
p.sku,
p.name,
p.price;
-- Przykładowy wynik:
-- action | sku | name | price
-- ---------+----------+---------------------+---------
-- UPDATE | SKU-001 | Laptop Dell XPS 15 | 5499.00
-- INSERT | SKU-NEW | Nowa Mysz Logitech | 299.00Najlepsze praktyki i typowe pułapki
1. Zawsze używaj tabeli tymczasowej jako źródła
Nie rób MERGE bezpośrednio z subquery na produkcyjnej tabeli. Najpierw załaduj dane do TEMP TABLE (przez COPY), a potem uruchom MERGE. Dzięki temu:
- Dane źródłowe są zamrożone – nie zmienią się w trakcie operacji
- Planner może lepiej zoptymalizować zapytanie
- Możesz zwalidować dane przed synchronizacją
2. Indeksuj kolumny JOIN
Kolumna, po której łączysz źródło z celem (ON p.sku = f.sku), musi być zaindeksowana po stronie celu. W przeciwnym razie PostgreSQL zrobi sekwencyjny skan – przy dużych tabelach to katastrofa.
3. Unikaj duplikatów w źródle
MERGE wymaga, żeby każdy wiersz docelowy pasował do co najwyżej jednego wiersza źródłowego. Jeśli źródło ma duplikaty na kolumnie JOIN, dostaniesz błąd. Użyj DISTINCT ON w CTE.
4. Obsłuż NULL-e poprawnie
Porównanie NULL != NULL zwraca NULL (nie TRUE). Użyj IS DISTINCT FROM:
-- Źle: NULL != NULL to NULL, nie TRUE
WHEN MATCHED AND (p.email != f.email) THEN ...
-- Dobrze: IS DISTINCT FROM obsługuje NULL-e
WHEN MATCHED AND (
p.email IS DISTINCT FROM f.email OR
p.city IS DISTINCT FROM f.city
) THEN
UPDATE SET email = f.email, city = f.city;5. Monitoruj wydajność
Użyj EXPLAIN ANALYZE żeby sprawdzić plan zapytania:
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
MERGE INTO products p
USING supplier_feed f ON p.sku = f.sku
WHEN MATCHED THEN UPDATE SET stock = f.stock
WHEN NOT MATCHED THEN INSERT (sku, name, price, stock)
VALUES (f.sku, f.name, f.price, f.stock);
-- Szukaj:
-- Hash Join lub Merge Join (dobre)
-- Nested Loop z Seq Scan (złe na dużych tabelach)Podsumowanie
MERGE w PostgreSQL to jedno z najpotężniejszych narzędzi do synchronizacji danych. Pozwala w jednym atomowym zapytaniu wykonać INSERT, UPDATE i DELETE na podstawie danych źródłowych.
Kiedy stosować MERGE?
- Import danych z plików CSV/XML od dostawców (dropshipping, e-commerce)
- Synchronizacja tabel w hurtowniach danych (ETL/ELT)
- Replikacja danych między systemami
- Aktualizacja cache tabel na podstawie świeżych danych
- Implementacja Slowly Changing Dimensions (SCD Type 1/2)
Kluczowe punkty
- Wydajność: 1 zapytanie zamiast 500 000 → sekundy zamiast godzin
- Atomowość: Cała operacja albo się powiedzie, albo zostanie wycofana
- Elastyczność: Wiele klauzul WHEN z warunkami AND
- Wzorzec: COPY → TEMP TABLE → MERGE → gotowe
Jeśli pracujesz z bazami danych i jeszcze nie korzystasz z MERGE – czas zacząć. To polecenie, które powinien znać każdy backend developer i data engineer.
Chcesz wdrożyć podobną stronę albo poprawić SEO?
Pracuję lokalnie w Lublin i zdalnie w całej Polsce. Jeśli ten temat dotyczy Twojej strony, poniżej masz dwa sensowne następne kroki.
Główny landing dla firm usługowych, które chcą więcej telefonów i formularzy.