Module A-9·17 min read

PostgreSQL 18 introduces OLD and NEW in RETURNING. This eliminates entire classes of application-level race conditions.

Written by Jatin Jain Saraf · Architect of SupraScan

Module 9 — The RETURNING Clause Evolved: OLD/NEW Aliases and Eliminating Race Conditions

What this module covers: PostgreSQL 18 introduces OLD and NEW aliases in the RETURNING clause of UPDATE and DELETE statements. 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 of OLD/NEW in RETURNING.


The Problem: Atomic Read-Modify-Write

A large fraction of application-database interactions follow this pattern:

  1. Read the current value of a row
  2. Compute a new value based on the old value
  3. 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 UPDATE acquires 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 & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.