INSERT, UPDATE, DELETE & RETURNING
PostgreSQL extends standard SQL DML (Data Manipulation Language) with the RETURNING clause, which lets you immediately see the rows that were modified — without a separate SELECT. This chapter covers all DML operations with their PostgreSQL-specific enhancements.
Setup
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
category TEXT,
price NUMERIC(10,2),
stock INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE audit_log (
id SERIAL PRIMARY KEY,
table_name TEXT,
operation TEXT,
row_id INTEGER,
changed_at TIMESTAMPTZ DEFAULT NOW()
);
INSERT
Single Row Insert
INSERT INTO products (name, category, price, stock)
VALUES ('Widget Pro', 'Hardware', 29.99, 100);
Multi-Row Insert
PostgreSQL accepts multiple value tuples in a single INSERT — much more efficient than individual inserts:
INSERT INTO products (name, category, price, stock) VALUES
('Gadget X', 'Electronics', 149.99, 50),
('Doohickey', 'Hardware', 9.99, 200),
('Thingamajig', 'Electronics', 79.99, 30),
('Whatsit', 'Accessories', 24.99, 150);
INSERT ... SELECT
Insert results from a query — great for populating tables from other data:
-- Copy expensive products into a premium catalog
INSERT INTO premium_catalog (name, price, category)
SELECT name, price, category
FROM products
WHERE price > 100;
-- Initialize stock report from products
INSERT INTO stock_report (product_id, product_name, initial_stock)
SELECT id, name, stock FROM products;
Default Values
-- Use DEFAULT keyword for specific columns
INSERT INTO products (name, price, stock)
VALUES ('Basic Bolt', 1.99, DEFAULT); -- stock uses its DEFAULT 0
-- Insert using only defaults (rarely needed but valid)
INSERT INTO products DEFAULT VALUES; -- all columns get defaults or NULL
INSERT ... RETURNING — PostgreSQL-Specific
RETURNING sends back the inserted rows (including any server-generated values like sequences and DEFAULT timestamps) immediately. This is one of PostgreSQL's most useful features.
-- Get the generated ID after insert
INSERT INTO products (name, category, price)
VALUES ('New Item', 'Hardware', 49.99)
RETURNING id, name, created_at;
| id | name | created_at |
|---|---|---|
| 6 | New Item | 2024-03-15 09:23:11+00 |
-- Insert multiple rows and get all generated IDs
INSERT INTO products (name, price) VALUES
('Alpha', 9.99),
('Beta', 19.99),
('Gamma', 29.99)
RETURNING id, name, price;
| id | name | price |
|---|---|---|
| 7 | Alpha | 9.99 |
| 8 | Beta | 19.99 |
| 9 | Gamma | 29.99 |
RETURNING in CTEs — Chain Operations
-- Insert products and immediately log them
WITH new_products AS (
INSERT INTO products (name, category, price)
VALUES ('Super Widget', 'Hardware', 39.99)
RETURNING id, name
)
INSERT INTO audit_log (table_name, operation, row_id)
SELECT 'products', 'INSERT', id
FROM new_products;
RETURNING * returns all columns. RETURNING id returns only the id. You can also use expressions: RETURNING id, price * 1.1 AS price_with_tax. This is far more efficient than doing an INSERT followed by a SELECT to get the generated values.
UPDATE
Basic UPDATE
-- Update a single row
UPDATE products
SET price = 34.99, updated_at = NOW()
WHERE id = 1;
-- Update multiple rows
UPDATE products
SET stock = stock - 10
WHERE category = 'Hardware' AND stock >= 10;
UPDATE with Expressions
-- Apply 15% discount to all electronics
UPDATE products
SET
price = ROUND(price * 0.85, 2),
updated_at = NOW()
WHERE category = 'Electronics';
-- Apply tiered discounts with CASE
UPDATE products
SET price = CASE
WHEN price > 100 THEN ROUND(price * 0.80, 2) -- 20% off expensive items
WHEN price > 50 THEN ROUND(price * 0.90, 2) -- 10% off mid-range
ELSE ROUND(price * 0.95, 2) -- 5% off cheap items
END,
updated_at = NOW();
UPDATE ... FROM — PostgreSQL-Specific
Update one table using values from another table:
-- Update product prices based on a price_updates table
UPDATE products p
SET
price = pu.new_price,
updated_at = NOW()
FROM price_updates pu
WHERE p.id = pu.product_id;
-- Update stock from a shipment manifest
UPDATE products p
SET stock = p.stock + s.quantity
FROM shipments s
WHERE p.id = s.product_id
AND s.received_date = CURRENT_DATE;
UPDATE ... FROM is PostgreSQL-specific. Standard SQL uses a correlated subquery in SET instead: SET price = (SELECT new_price FROM price_updates WHERE product_id = p.id). The PostgreSQL FROM syntax is cleaner and often faster.
UPDATE ... RETURNING
-- Update and see what changed
UPDATE products
SET stock = stock - 1
WHERE id = 3
RETURNING id, name, stock AS new_stock, stock + 1 AS old_stock;
| id | name | new_stock | old_stock |
|---|---|---|---|
| 3 | Doohickey | 199 | 200 |
-- Update with RETURNING in a CTE to log changes
WITH updated AS (
UPDATE products
SET price = price * 1.05
WHERE category = 'Hardware'
RETURNING id, name, price AS new_price
)
INSERT INTO audit_log (table_name, operation, row_id)
SELECT 'products', 'PRICE_UPDATE', id FROM updated;
DELETE
Basic DELETE
-- Delete a specific row
DELETE FROM products WHERE id = 5;
-- Delete by condition
DELETE FROM products WHERE stock = 0 AND category = 'Discontinued';
DELETE ... USING — PostgreSQL-Specific
Like UPDATE ... FROM, you can use another table to filter the delete:
-- Delete products that are in the discontinued list
DELETE FROM products p
USING discontinued_items d
WHERE p.id = d.product_id;
-- Delete orders from inactive customers
DELETE FROM orders o
USING customers c
WHERE o.customer_id = c.id
AND c.status = 'inactive'
AND o.order_date < NOW() - INTERVAL '1 year';
DELETE ... RETURNING
-- Delete and capture what was deleted
DELETE FROM products
WHERE stock = 0
RETURNING id, name, category;
| id | name | category |
|---|---|---|
| 4 | Thingamajig | Electronics |
-- Archive deleted records
WITH deleted AS (
DELETE FROM sessions
WHERE expires_at < NOW()
RETURNING *
)
INSERT INTO expired_sessions SELECT * FROM deleted;
Multi-Row VALUES
The VALUES clause can be used as a standalone table expression, not just in INSERT:
-- Use VALUES as a derived table
SELECT v.id, v.name, p.stock
FROM (VALUES (1, 'Widget Pro'), (3, 'Doohickey')) AS v(id, name)
JOIN products p ON p.id = v.id;
-- Update multiple rows with different values each
UPDATE products AS p
SET price = v.new_price
FROM (VALUES
(1, 24.99),
(2, 139.99),
(3, 8.99)
) AS v(product_id, new_price)
WHERE p.id = v.product_id;
Practical Patterns
Conditional Insert (Check Before Insert)
-- Insert only if the name doesn't exist (see also ON CONFLICT in ch. 10)
INSERT INTO products (name, price)
SELECT 'New Widget', 9.99
WHERE NOT EXISTS (SELECT 1 FROM products WHERE name = 'New Widget');
Soft Delete Pattern
Instead of actually deleting rows, mark them as deleted:
ALTER TABLE products ADD COLUMN deleted_at TIMESTAMPTZ;
-- Soft delete
UPDATE products SET deleted_at = NOW() WHERE id = 5;
-- Always filter in queries
SELECT * FROM products WHERE deleted_at IS NULL;
-- Create a view for convenience
CREATE VIEW active_products AS
SELECT * FROM products WHERE deleted_at IS NULL;