MERGE

Learn how to use the PostgreSQL MERGE statement to conditionally insert, update, and delete rows in a target table based on a source table in a single command.

6 min read · Last updated: March 2026 · Back to overview

Quick Answer

The PostgreSQL MERGE statement (available since PostgreSQL 15) lets you insert, update, or delete rows in a target table based on matching rows in a source table, all in one atomic command. PostgreSQL 17 adds RETURNING clause support so you can see exactly which action was taken on each row.

Spin up a Postgres database in 20 seconds with Vela.

Try Vela Sandbox

The MERGE statement consolidates insert, update, and delete operations into a single command. You specify a target table to modify and a source table (or subquery) that provides new data, then define WHEN MATCHED and WHEN NOT MATCHED clauses that determine what happens to each row.

MERGE syntax and concepts

The basic structure of a MERGE statement:

MERGE INTO target_table
USING source_table ON match_condition
WHEN MATCHED AND condition THEN
  UPDATE SET column1 = value1, column2 = value2
WHEN MATCHED AND NOT condition THEN
  DELETE
WHEN NOT MATCHED THEN
  INSERT (column1, column2) VALUES (value1, value2)
RETURNING merge_action(), target_table.*;

Three key concepts: the target table is what you modify; the source table provides incoming data; the match condition in the ON clause links the two. You can include multiple WHEN clauses with additional AND conditions to route each row to a different action.

Practical MERGE example with RETURNING

Set up a products table and a table of incoming updates:

CREATE TABLE products (
  product_id  SERIAL PRIMARY KEY,
  name        TEXT UNIQUE,
  price       DECIMAL(10, 2),
  stock       INTEGER,
  status      TEXT,
  last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

INSERT INTO products (name, price, stock, status)
VALUES ('Laptop',   999.99, 50,  'active'),
       ('Keyboard',  79.99, 100, 'active'),
       ('Mouse',     29.99, 200, 'active');

CREATE TABLE product_updates (
  name   TEXT,
  price  DECIMAL(10, 2),
  stock  INTEGER,
  status TEXT
);

INSERT INTO product_updates VALUES
  ('Laptop',     1099.99, 75,  'active'),       -- update price and stock
  ('Monitor',     299.99, 30,  'active'),        -- new product
  ('Keyboard',      NULL,  0,  'discontinued'),  -- delete this product
  ('Headphones',   89.99, 50,  'active');        -- new product

Run the merge and capture what happened with RETURNING (PostgreSQL 17+):

MERGE INTO products p
USING product_updates u ON p.name = u.name
WHEN MATCHED AND u.status = 'discontinued' THEN
  DELETE
WHEN MATCHED AND u.status = 'active' THEN
  UPDATE SET
    price        = COALESCE(u.price, p.price),
    stock        = u.stock,
    status       = u.status,
    last_updated = CURRENT_TIMESTAMP
WHEN NOT MATCHED AND u.status = 'active' THEN
  INSERT (name, price, stock, status)
  VALUES (u.name, u.price, u.stock, u.status)
RETURNING merge_action() AS action,
          p.product_id, p.name, p.price, p.stock, p.status;
 action | product_id |    name    |   price  | stock | status
--------+------------+------------+----------+-------+--------
 UPDATE |          1 | Laptop     | 1099.99  |   75  | active
 INSERT |          4 | Monitor    |  299.99  |   30  | active
 DELETE |          2 | Keyboard   |   79.99  |  100  | active
 INSERT |          5 | Headphones |   89.99  |   50  | active

Production tips for MERGE

  • Index the columns used in the ON match condition on both tables to avoid sequential scans on large source sets.
  • Ensure the ON condition produces at most one source row per target row; ambiguous matches cause a cardinality violation error.
  • Use a CTE or subquery as the source to clean or deduplicate data before the merge: USING (SELECT DISTINCT ON (name) * FROM product_updates ORDER BY name) u ON ....
  • Use COALESCE(u.price, p.price) in the SET clause to preserve existing values when the incoming row has NULLs.
  • Wrap the entire MERGE in an explicit transaction when it is part of a larger data pipeline so partial failures roll back cleanly.

Continue in Modifying Data: Back to tutorial overview.

Related in this section: INSERT · INSERT Multiple Rows · UPDATE

Frequently Asked Questions

When was the MERGE statement added to PostgreSQL?

MERGE was introduced in PostgreSQL 15, released in October 2022. PostgreSQL 17 extended it with RETURNING clause support and the ability to use updatable views as the target.

What is the difference between MERGE and INSERT ON CONFLICT?

INSERT ON CONFLICT handles a single insert that may conflict with an existing row. MERGE is more general: it joins a source table to a target table and can perform different combinations of INSERT, UPDATE, and DELETE based on multiple WHEN clauses in one statement.

Can MERGE delete rows that are not in the source?

No. MERGE only acts on rows that appear in the source. To delete target rows that have no match in the source, you need a separate DELETE ... WHERE NOT EXISTS query.

Is the MERGE statement atomic?

Yes. All inserts, updates, and deletes performed by a MERGE execute as a single atomic unit. If an error occurs mid-statement, none of the changes are committed.

What privileges are needed to run MERGE?

You need SELECT on the source table, and INSERT, UPDATE, or DELETE privileges on the target table corresponding to each WHEN clause action your statement uses.