PostgreSQL 18 introduces OLD and NEW in RETURNING. This eliminates entire classes of application-level race conditions.
Module 9 — The RETURNING Clause Evolved: OLD/NEW Aliases and Eliminating Race Conditions
What this module covers: PostgreSQL 18 introduces
OLDandNEWaliases in theRETURNINGclause ofUPDATEandDELETEstatements. This sounds like a minor syntactic addition. It is not. It eliminates entire classes of application-level race conditions that previously required either two round trips, advisory locks, or explicit table locks to handle safely. This module explains the problem, the pre-18 workarounds and their costs, and the precise semantics ofOLD/NEWinRETURNING.
The Problem: Atomic Read-Modify-Write
A large fraction of application-database interactions follow this pattern:
- Read the current value of a row
- Compute a new value based on the old value
- Write the new value back
The canonical example: decrementing an account balance.
-- Application code (naive, broken under concurrency):
SELECT balance FROM accounts WHERE id = $1;
-- application computes: new_balance = balance - 100
UPDATE accounts SET balance = $2 WHERE id = $1;
This is a classic read-modify-write race condition. Between the SELECT and the UPDATE, another transaction can modify the balance. Two concurrent withdrawals of $100 from an account with $150 can both read $150, both compute $50 as the new balance, and both write $50 — leaving the account with $50 instead of the correct $-50 (or a rejected second withdrawal).
The correct SQL-level solution is an atomic update:
UPDATE accounts SET balance = balance - 100 WHERE id = $1;
This executes atomically. No race condition. But it discards information: what was the balance before the update?
In many real-world cases, you need the previous value to make a decision in the application:
- Was the balance sufficient? (if old balance < 100, reject the transaction)
- What changed? (for audit logs, event sourcing, change feeds)
- What was the previous state? (for optimistic concurrency control)
- Did the row actually exist before? (to distinguish "updated" from "not found")
Pre-PG18 Workarounds
Workaround 1: Two Round Trips with Locking
BEGIN;
-- Lock the row so no one else can modify it between SELECT and UPDATE
SELECT balance FROM accounts WHERE id = $1 FOR UPDATE;
-- Application checks: if balance < 100, ROLLBACK and return error
-- Otherwise:
UPDATE accounts SET balance = balance - 100 WHERE id = $1;
COMMIT;
The cost:
- Two database round trips (SELECT + UPDATE) instead of one
FOR UPDATEacquires a row-level exclusive lock — concurrent reads are not blocked, but concurrent writes on the same row queue up- Lock held for the full duration of application processing between the SELECT and UPDATE
- Deadlock risk if multiple rows are locked in different orders by concurrent transactions
Workaround 2: CTE with Data-Modifying CTE
WITH updated AS (
UPDATE accounts
SET balance = balance - 100
WHERE id = $1
RETURNING id, balance
)
SELECT
balance AS new_balance,
balance + 100 AS old_balance -- reverse-engineer the old value
FROM updated;
This is one round trip, but you're computing the old value from the new value. For additive changes (balance - 100), this is trivial. For complex transformations, it becomes error-prone. And for non-invertible changes (setting a value to a computed result from another table), it is impossible.
Workaround 3: BEFORE Trigger to Capture Old Value
CREATE TABLE balance_changes (
account_id BIGINT,
old_balance NUMERIC,
new_balance NUMERIC,
changed_at TIMESTAMPTZ DEFAULT now()
);
CREATE FUNCTION capture_balance_change() RETURNS TRIGGER AS $$
BEGIN
INSERT INTO balance_changes VALUES (OLD.id, OLD.balance, NEW.balance, now());
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER before_balance_update
BEFORE UPDATE ON accounts
FOR EACH ROW EXECUTE FUNCTION capture_balance_change();
The cost:
- Extra table, extra trigger, extra INSERT on every update
- The old value is in a separate table — application must join to retrieve it
- Trigger maintenance overhead (documented in Module 4 — triggers prevent HOT updates)
- Not usable for ad-hoc queries; requires schema setup in advance
Workaround 4: Subquery in RETURNING
UPDATE accounts
SET balance = balance - 100
WHERE id = $1
RETURNING
balance AS new_balance,
(SELECT balance FROM accounts WHERE id = $1) AS old_balance;
This does not work correctly. The subquery in RETURNING sees the post-update state of the row (in the same transaction). balance in the subquery returns the updated value, not the original. This is a commonly attempted workaround that silently produces wrong results.
PostgreSQL 18: OLD and NEW in RETURNING
PG18 solves this cleanly. UPDATE and DELETE statements can now use OLD.column and NEW.column aliases in the RETURNING clause to access both the pre-update and post-update values of any column in a single statement.
Basic Syntax
-- Return both old and new balance in one atomic UPDATE
UPDATE accounts
SET balance = balance - 100
WHERE id = $1
RETURNING
OLD.balance AS balance_before,
NEW.balance AS balance_after,
NEW.id;
-- Result:
-- balance_before | balance_after | id
-- ---------------+---------------+----
-- 1500 | 1400 | 42
One round trip. Atomic. No locks beyond what the UPDATE itself requires. No reverse-engineering the old value. No triggers.
DELETE with OLD
DELETE only has OLD — there is no NEW row after deletion.
DELETE FROM sessions
WHERE user_id = $1
AND expires_at < now()
RETURNING
OLD.id AS deleted_session_id,
OLD.created_at AS session_created,
OLD.expires_at AS session_expired;
Returns every deleted row's pre-deletion state. Useful for:
- Audit logging of what was deleted
- Triggering downstream cleanup based on the deleted row's values
- Confirming which specific rows were deleted (when the WHERE clause could match multiple)
Unqualified Columns in RETURNING
Sign in to keep reading
The rest of this module is free — sign in with Google to unlock it and track your progress.
Sign in & RegisterDiscussion
0Join the discussion