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.
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:
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
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
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
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
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
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.
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
For backward compatibility, unqualified column names in RETURNING continue to refer to NEW for UPDATE and OLD for DELETE:
Prefer explicit OLD. / NEW. in new code — it makes intent unambiguous and is self-documenting.
Patterns Enabled by OLD/NEW RETURNING
Pattern 1: Atomic Conditional Update with Decision
The most common use case: update a row only if a condition is met, and know in the application whether the condition was satisfied.
Before PG18, distinguishing "row not found" from "condition failed" required either a separate existence check or careful result parsing. Now: zero rows means the condition failed (or the row doesn't exist), one row means success — and you have full before/after context.
Pattern 2: Optimistic Concurrency Control
Optimistic concurrency uses a version counter or timestamp to detect concurrent modifications without holding locks.
The application gets confirmation of both the version it expected and the version that was written — useful for logging and debugging concurrent modification conflicts.
Pattern 3: State Machine Transitions
Many business objects have a state machine — pending → processing → completed. A common requirement: only transition if the current state is the expected state, and know what state it was in.
Before PG18, this pattern required either a SELECT FOR UPDATE to check the current status before updating, or inferring success from update count alone (which doesn't tell you if the condition failed or the row was missing).
Pattern 4: Audit Log from the Update Itself
Instead of a trigger (which prevents HOT updates and adds schema complexity), generate an audit log entry from the RETURNING clause directly in the application:
The entire operation — update the account, log the change — in a single transaction, with no trigger, no extra round trip, and no HOT update penalty. If the update fails (insufficient balance), no audit row is inserted. Atomicity is guaranteed by the transaction.
Pattern 5: Soft Delete with History
The application receives the pre-deletion profile in one round trip, can write it to a data warehouse or archive table, without any additional reads.
What OLD/NEW Does NOT Cover
INSERT: Only NEW Exists
INSERT ... RETURNING only has NEW (the inserted row). There is no OLD for inserts — the row didn't exist before.
UPSERT (INSERT ON CONFLICT): Full Access
INSERT ... ON CONFLICT DO UPDATE ... RETURNING gains the most from OLD/NEW:
When the ON CONFLICT DO UPDATE branch executes, OLD contains the pre-upsert values. When the INSERT branch executes (no conflict), OLD columns are NULL — the row didn't exist before. The application can distinguish insert vs update from OLD.login_count IS NULL.
Triggers Still See OLD/NEW Separately
Row-level triggers have always had OLD and NEW records. PG18's RETURNING OLD/NEW is a separate feature — it gives the SQL layer the same access that trigger functions have always had internally. The two mechanisms are independent.
The Concurrency Guarantee
The OLD values returned by RETURNING OLD are the values that existed in the row at the moment the row lock was acquired for the UPDATE or DELETE, which is the same moment the update itself executes. This means:
OLDreflects the committed state of the row just before this transaction modified it- No other transaction can have modified the row between when
OLDwas captured and whenNEWwas written — the row lock prevents it - The old-to-new transition is atomic from every other transaction's perspective
This is a stronger guarantee than SELECT ... FOR UPDATE + UPDATE in two round trips, because with two round trips there is always the question of whether the application correctly used the locked value. With RETURNING OLD, the database captures and returns the old value as part of the single atomic operation — no application logic is in the critical path between read and write.
Summary
| Scenario | Before PG18 | After PG18 |
|---|---|---|
| Get old value after UPDATE | SELECT FOR UPDATE + UPDATE (2 RTT) | RETURNING OLD.column (1 RTT) |
| Conditional update + decision | 2 RTT + lock | 1 RTT, 0 rows = condition failed |
| Detect concurrent modification | Advisory locks or version check loop | RETURNING OLD.version in one statement |
| Audit log without trigger | Trigger (prevents HOT) | CTE with RETURNING OLD into audit table |
| State machine transition | SELECT FOR UPDATE + check + UPDATE | WHERE status = 'pending' RETURNING OLD.status |
| Soft delete with profile | SELECT + UPDATE (2 RTT) | RETURNING OLD.* (1 RTT) |
| UPSERT: insert vs update | Check affected rows + re-query | RETURNING OLD.column IS NULL → was insert |
The principle: any operation that previously required reading before writing to know what changed can now be expressed as a single atomic statement. The database captures the transition — both the before and after state — and returns it to the application as part of the write operation itself.
Module 10 closes the core architecture loop with replication and high availability — the CAP trade-offs, the consistency models of each replication mode, and the operational decisions that separate a database that survives incidents from one that doesn't.
Next: Module 10 — Replication, High Availability, and the CAP Trade-offs →
A financial application needs to atomically debit an account, but also requires the account's balance *before* and *after* the debit for auditing and to determine if the transaction was successful (i.e., sufficient funds existed). The application must minimize database round trips and avoid deadlocks in a high-concurrency environment. Which approach, leveraging PostgreSQL 18 features, provides the most robust and performant solution?
A Senior Software Engineer is designing a critical state machine transition for a jobs table, where a job can only move from pending to processing if it hasn't already been claimed by another worker. They need to ensure atomicity and retrieve the previous_status and current_status in a single database interaction. They are considering RETURNING OLD.status, NEW.status in their UPDATE statement. What is the fundamental concurrency guarantee provided by OLD values in this PostgreSQL 18 RETURNING clause?
An application uses an UPSERT statement (INSERT ... ON CONFLICT DO UPDATE ...) to track user login statistics, incrementing login_count and updating last_login. The application needs to know if a new user record was created or an existing one was updated, and in the latter case, retrieve the previous_login_count and current_login_count. Which RETURNING clause and application logic correctly achieves this, and what would be the expected output for a newly inserted user?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
Sign in & RegisterDiscussion
0Join the discussion