Upsert & RETURNING

Two modern features make data changes simpler. An upsert inserts a row, or updates it if it already exists, in a single statement — no "check first, then decide" logic. RETURNING hands back the rows a statement changed, so you don't need a follow-up query.

The problem

SQL
INSERT INTO departments (id, name, location) VALUES (4, 'Support', 'Cork');
Result
Error: UNIQUE constraint failed: departments.id

Department 4 exists already, so the insert fails. Checking with a SELECT first and then choosing INSERT or UPDATE works, but two users doing it at the same time can still collide.

INSERT … ON CONFLICT DO UPDATE

SQL
INSERT INTO departments (id, name, location)
VALUES (4, 'Support', 'Cork')
ON CONFLICT (id) DO UPDATE SET location = excluded.location;

SELECT * FROM departments WHERE id = 4;
Result 1 row
idnamelocation
4SupportCork

If inserting would violate the unique key named in ON CONFLICT (…), the database updates the existing row instead. excluded refers to the row you tried to insert. This syntax is used by PostgreSQL and SQLite.

Upserting counters

SQL
CREATE TABLE product_views (
  product_id INTEGER PRIMARY KEY,
  views INTEGER NOT NULL
);

INSERT INTO product_views VALUES (4, 1) ON CONFLICT (product_id) DO UPDATE SET views = views + 1;
INSERT INTO product_views VALUES (4, 1) ON CONFLICT (product_id) DO UPDATE SET views = views + 1;
INSERT INTO product_views VALUES (7, 1) ON CONFLICT (product_id) DO UPDATE SET views = views + 1;

SELECT * FROM product_views;
Result 2 rows
product_idviews
42
71

The first view of a product inserts a row; later views increment it.

ON CONFLICT DO NOTHING

SQL
INSERT INTO customers (id, name, email, country, signup_date)
VALUES (1, 'Ana Pereira', 'ana.pereira@example.com', 'Portugal', '2024-02-11')
ON CONFLICT DO NOTHING;

SELECT COUNT(*) AS customers FROM customers;
Result 1 row
customers
10

Silently skips rows that already exist — useful when importing data that may overlap with what's already there.

Upserts in other databases

DatabaseSyntax
PostgreSQL, SQLiteINSERT … ON CONFLICT (key) DO UPDATE SET …
MySQL / MariaDBINSERT … ON DUPLICATE KEY UPDATE col = VALUES(col)
Standard SQL, SQL Server, Oracle, PostgreSQL 15+MERGE INTO target USING source ON … WHEN MATCHED THEN UPDATE … WHEN NOT MATCHED THEN INSERT …
SQL mysql
INSERT INTO departments (id, name, location)
VALUES (4, 'Support', 'Cork')
ON DUPLICATE KEY UPDATE location = VALUES(location);

RETURNING

SQL
INSERT INTO products (name, category, price, stock)
VALUES ('Reading Light', 'Home', 27.50, 20)
RETURNING id, name;
Result 1 row
idname
13Reading Light

RETURNING gives back columns from the affected rows — here the newly generated id, without a second query. It works with UPDATE and DELETE too:

SQL
UPDATE products
SET price = round(price * 0.8, 2)
WHERE category = 'Toys'
RETURNING name, price AS sale_price;
Result 2 rows
namesale_price
Jigsaw Puzzle 100014.4
Wooden Train Set31.92
SQL
DELETE FROM order_items
WHERE order_id = 5
RETURNING order_id, product_id, quantity;
Result 1 row
order_idproduct_idquantity
561

RETURNING is supported by PostgreSQL, SQLite (3.35+), MariaDB and Oracle 23ai; SQL Server's equivalent is the OUTPUT clause.

Try it yourself

Record stock deliveries: add 10 to existing products and create rows for new ones, returning the results.

Try it yourself
Result