proscinski.com

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

Pełna składnia MERGE w PostgreSQL
SQL
[ 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.

KlauzulaKiedy się odpalaDozwolone akcje
WHEN MATCHEDWiersz istnieje w źródle i w celuUPDATE, DELETE, DO NOTHING
WHEN NOT MATCHED [BY TARGET]Wiersz istnieje tylko w źródleINSERT, DO NOTHING
WHEN NOT MATCHED BY SOURCEWiersz istnieje tylko w celuUPDATE, 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.

Przygotowanie tabel
SQL
-- 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 – synchronizacja klientów
SQL
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 i import CSV
SQL
-- 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 – pełna synchronizacja stanów magazynowych
SQL
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 oferuje

Porównanie wydajności

MetodaLiczba zapytań SQLCzas (500k rekordów)Atomowość
WP All Import (rekord po rekordzie)~1 000 0002-6 godzinBrak
Pętla UPDATE/INSERT w aplikacji~500 00030-90 minutBrak
Batch UPDATE + INSERT~1 000 (batche po 500)1-5 minutCzęściowa
MERGE13-15 sekundPeł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?

UPSERT – INSERT ... ON CONFLICT
SQL
-- 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 MATCHED

Kiedy co wybrać?

CechaINSERT ... ON CONFLICTMERGE
Dostępny odPostgreSQL 9.5PostgreSQL 15
OperacjeINSERT + UPDATEINSERT + UPDATE + DELETE
Wymaga constraintuTak (UNIQUE/PK)Nie – JOIN dowolny
Wiele klauzul WHENNieTak (z warunkami AND)
RETURNING (PG 17+)TakTak + merge_action()
Bezpieczeństwo współbieżneGwarantowaneMożliwy unique violation
Wydajność bulk opsDobraLepsza 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.

SCD Type 1 – MERGE w hurtowni danych
SQL
-- 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.

SCD Type 2 – historia zmian z MERGE
SQL
-- 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.

MERGE z CTE – transformacja danych przed synchronizacją
SQL
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.

RETURNING – audyt operacji MERGE
SQL
-- 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.00

Najlepsze 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:

Bezpieczne porównanie z NULL-ami
SQL
-- Ź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:

Analiza wydajności MERGE
SQL
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.

TelefonBezpłatna wycena