Module A-9·23 min read

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

JJS
Written by Jatin Jain Saraf · Senior Software Engineer

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.

sql

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:

sql

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

sql

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

sql

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

sql

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

sql

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

sql

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.

sql

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.