Postgres Superpowers: RETURNING on every write, ON CONFLICT (UPSERT), UPDATE … FROM, DELETE … USING, data-modifying CTEs, MERGE (PG 15+, RETURNING on MERGE in PG 17+)
MVCC Reminder: An UPDATE writes a new row version and marks the old one dead; DELETE marks it dead — VACUUM cleans up later
Sample Data: E-commerce Practice Queries — all examples run inside BEGIN … ROLLBACK so the data stays unchanged
INSERT
Forms
BEGIN;-- Single rowINSERT INTO users (email, full_name, country)VALUES ('ivan@example.com', 'Ivan Petrov', 'RS');-- Multi-row (one round trip, much faster than many single inserts)INSERT INTO categories (name, parent_id) VALUES ('Tablets', 1), ('Garden', NULL);-- From a queryCREATE TEMP TABLE vip_users AS SELECT id, email FROM users WHERE false; -- empty copyINSERT INTO vip_users (id, email)SELECT u.id, u.emailFROM users uWHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.status = 'delivered');-- All defaultsCREATE TEMP TABLE audit_ping (id int GENERATED ALWAYS AS IDENTITY, at timestamptz DEFAULT now());INSERT INTO audit_ping DEFAULT VALUES;SELECT count(*) AS vip_count FROM vip_users;ROLLBACK;
RETURNING
What: Return columns of the inserted/updated/deleted rows — no second SELECT round trip
Typical Use: Get generated IDs, defaults, computed values
Works On: INSERT, UPDATE, DELETE, and MERGE (PG 17+)
BEGIN;INSERT INTO orders (user_id) VALUES (7)RETURNING id, status, created_at; -- status shows the DEFAULT 'pending'ROLLBACK;
UPSERT: INSERT … ON CONFLICT
Syntax
INSERT INTO t (cols) VALUES (…)ON CONFLICT (conflict_target) -- unique columns / ON CONSTRAINT name / partial index predicateDO NOTHING -- skip the row| DO UPDATE SET col = EXCLUDED.col -- EXCLUDED = the row you tried to insert [WHERE condition] -- only update when this holds
Requires: A unique index or constraint matching the conflict target
Atomic: Safe under concurrency — no “check then insert” race
EXCLUDED: Pseudo-table holding the proposed row; the existing row is referenced by the table name
Limitation: The same key can’t appear twice in one INSERT … ON CONFLICT DO UPDATE statement (error: “command cannot affect row a second time”)
BEGIN;-- DO NOTHING: idempotent insert (e.g. replaying events)INSERT INTO users (email, full_name) VALUES ('asha@example.com', 'Asha Duplicate')ON CONFLICT (email) DO NOTHINGRETURNING id; -- returns 0 rows: nothing inserted-- DO UPDATE: set price from a feed, only if it actually changedINSERT INTO products (sku, name, category_id, price)VALUES ('LAP-001', 'Air Laptop 13', 3, 949.00), ('LAP-003', 'Air Laptop 15', 3, 1299.00)ON CONFLICT (sku) DO UPDATE SET price = EXCLUDED.price WHERE products.price IS DISTINCT FROM EXCLUDED.priceRETURNING sku, price, (xmax = 0) AS inserted;-- Counter pattern: increment or createCREATE TEMP TABLE page_views (path text PRIMARY KEY, views bigint NOT NULL);INSERT INTO page_views VALUES ('/home', 1)ON CONFLICT (path) DO UPDATE SET views = page_views.views + 1;INSERT INTO page_views VALUES ('/home', 1)ON CONFLICT (path) DO UPDATE SET views = page_views.views + 1;SELECT * FROM page_views; -- /home | 2ROLLBACK;
Approach
Race-Safe
Notes
SELECT then INSERT/UPDATE in app
No
Two requests both see “missing” → duplicate error
INSERT … ON CONFLICT
Yes
Postgres-native; needs unique index
MERGE
Not fully
Can still raise unique violations under concurrent inserts; use ON CONFLICT for pure upserts
Gotcha: If the FROM produces several matches for one target row, Postgres picks one arbitrarily — aggregate first
BEGIN;-- Mark orders as 'paid' when they have a successful payment covering the totalUPDATE orders oSET status = 'paid'FROM (SELECT order_id, sum(amount) AS paid FROM payments WHERE status = 'succeeded' GROUP BY order_id) p, (SELECT order_id, sum(quantity * unit_price) AS due FROM order_items GROUP BY order_id) dWHERE p.order_id = o.id AND d.order_id = o.id AND o.status = 'pending' AND p.paid >= d.dueRETURNING o.id, o.status; -- 0 rows: pending orders are unpaid in the seedROLLBACK;
BEGIN;UPDATE users uSET country = 'XX'WHERE country IS NULL AND NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)RETURNING id, full_name, country;ROLLBACK;
DELETE
DELETE … USING (Join in a Delete)
BEGIN;-- Delete failed payments for delivered ordersDELETE FROM payments pUSING orders oWHERE o.id = p.order_id AND o.status = 'delivered' AND p.status = 'failed'RETURNING p.id, p.order_id, p.amount;ROLLBACK;
Batch Deletes on Big Tables
Problem: One huge DELETE holds locks long, bloats the table, and generates a WAL spike
Fix: Delete in batches by primary key, commit between batches; or partition by time and DROP/DETACH PARTITION (see Database Partition)
BEGIN;-- One batch of at most 1000 rows; loop this from a script until 0 rows are affectedDELETE FROM paymentsWHERE id IN ( SELECT id FROM payments WHERE status = 'failed' AND paid_at < now() - interval '90 days' ORDER BY id LIMIT 1000);ROLLBACK;
MERGE (PG 15+)
Concept
What: SQL-standard “synchronise target with source” — one statement with WHEN MATCHED / WHEN NOT MATCHED branches
Actions: UPDATE, DELETE, INSERT, DO NOTHING
PG 17+: WHEN NOT MATCHED BY SOURCE (target rows missing from source) and RETURNING with merge_action()
vs ON CONFLICT: MERGE can delete and handle several conditions, but is not race-proof for inserts; ON CONFLICT is the safer pure upsert
BEGIN;CREATE TEMP TABLE supplier_feed (sku text PRIMARY KEY, price numeric(10,2), stock int, discontinued boolean);INSERT INTO supplier_feed VALUES ('ACC-001', 24.00, 300, false), -- update ('ACC-003', 45.00, 0, true), -- discontinued → deactivate ('ACC-009', 15.00, 500, false); -- new product → insertMERGE INTO products pUSING supplier_feed f ON f.sku = p.skuWHEN MATCHED AND f.discontinued THEN UPDATE SET is_active = false, stock = 0WHEN MATCHED THEN UPDATE SET price = f.price, stock = f.stockWHEN NOT MATCHED AND NOT f.discontinued THEN INSERT (sku, name, category_id, price, stock) VALUES (f.sku, 'New item ' || f.sku, 4, f.price, f.stock)RETURNING merge_action(), p.sku, p.price, p.stock, p.is_active; -- PG 17+ROLLBACK;
Data-Modifying CTEs
What: INSERT/UPDATE/DELETE … RETURNING inside WITH, chained in one atomic statement
Snapshot Rule: All sub-statements see the same snapshot — they can’t see each other’s changes except through RETURNING
BEGIN;CREATE TEMP TABLE payments_archive (LIKE payments);-- Move failed payments to an archive table in one statementWITH moved AS ( DELETE FROM payments WHERE status = 'failed' RETURNING *)INSERT INTO payments_archiveSELECT * FROM movedRETURNING id, order_id, status;ROLLBACK;
Bulk Loading with COPY
COPY vs INSERT
Method
Speed
Notes
Single-row INSERT
Slowest
One round trip + one commit per row if autocommit
Multi-row INSERT
Good
Batch 500–1000 rows per statement
COPY
Fastest
Streams rows, minimal parsing overhead
COPY into UNLOGGED/staging + INSERT … SELECT
Fastest for ETL
Validate/transform in SQL, then merge
-- Export a query to stdout as CSV (psql prints it)COPY (SELECT sku, name, price FROM products WHERE category_id = 8 ORDER BY sku)TO STDOUT WITH (FORMAT csv, HEADER);
# Server-side COPY reads files on the DB server (needs superuser / pg_read_server_files)# COPY products_staging FROM '/var/lib/postgresql/products.csv' WITH (FORMAT csv, HEADER);# Client-side \copy (psql) reads a file on YOUR machine — usually what you wantpsql -c "\copy products_staging FROM 'products.csv' WITH (FORMAT csv, HEADER)"
Fast Bulk Load Checklist
Load into a Staging Table: Then INSERT … SELECT … ON CONFLICT into the real table
Drop / Defer Indexes: Create indexes after loading big batches
Large Transaction: One commit per batch, not per row
ANALYZE Afterwards: So the planner knows the new data distribution
COPY FREEZE: For a table created/truncated in the same transaction, avoids later vacuum work
PG 17: COPY … ON_ERROR ignore skips bad rows instead of failing the whole load
Interview Questions
“How do you do an upsert in Postgres?”: INSERT … ON CONFLICT (key) DO UPDATE SET col = EXCLUDED.col
“MERGE vs ON CONFLICT?”: MERGE handles insert/update/delete branches from a source; ON CONFLICT is the concurrency-safe upsert
“Get the generated ID after insert?”: RETURNING id
“Update using another table?”: UPDATE … FROM; make sure each target row matches at most one source row
“Delete millions of rows safely?”: Batches by PK with commits, or drop a time partition
“Fastest way to load 10M rows?”: COPY into a staging table, build indexes after, ANALYZE
Summary
INSERT: Multi-row VALUES, INSERT … SELECT, RETURNING for generated values
UPSERT: ON CONFLICT DO NOTHING / DO UPDATE with EXCLUDED, race-safe given a unique index
UPDATE / DELETE: FROM / USING for joins; aggregate the source to avoid ambiguous matches
MERGE: Standard multi-branch sync (PG 15+), RETURNING + NOT MATCHED BY SOURCE in PG 17