BUG #19536: UPDATE RETURNING OLD value is stale after concurrent update when table has a BEFORE UPDATE trigger

Started by PG Bug reporting form22 days ago7 messagesbugs
Jump to latest
#1PG Bug reporting form
noreply@postgresql.org

The following bug has been logged on the website:

Bug reference: 19536
Logged by: Jonas Boberg
Email address: bobergj@gmail.com
PostgreSQL version: 18.4
Operating system: Docker image postgres:18.4-alpine3.23
Description:

In an UPDATE ... RETURNING OLD under isolation level READ COMMITTED, when
the table has a BEFORE UPDATE trigger, the OLD value is stale after a
concurrent update.

SELECT version();

PostgreSQL 18.4 on aarch64-unknown-linux-musl, compiled by gcc (Alpine

15.2.0) 15.2.0, 64-bit

## How to reproduce

Setup:
-------
CREATE TABLE t (
id int PRIMARY KEY,
n int NOT NULL
);

CREATE FUNCTION before_update_noop()
RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
RETURN NEW;
END;
$$;

CREATE TRIGGER t_before_update
BEFORE UPDATE ON t
FOR EACH ROW EXECUTE FUNCTION before_update_noop();

INSERT INTO t VALUES (1, 7);
-----

psql session 1:
------
BEGIN ISOLATION LEVEL READ COMMITTED;

UPDATE t
SET n = n + 10
WHERE id = 1;
-- do not commit, hold the row lock
------
Leave session 1 open

psql session 2:
-----
BEGIN ISOLATION LEVEL READ COMMITTED;
UPDATE t
SET n = n + 1
WHERE id = 1
RETURNING
OLD.n AS old_n,
NEW.n AS new_n,
NEW.n - 1 AS expected_old;
----
Session 2 blocks. Now commit in session 1:
----
COMMIT;
----

## Actual result

Session 2 outputs:
old_n | new_n | expected_old
-------+-------+--------------
7 | 18 | 17
(1 row)

Here OLD.n is the pre-concurrent update value, while NEW.n is the value
written by the concurrent update.

## Expected result

old_n | new_n | expected_old
-------+-------+--------------
17 | 18 | 17

If I drop the trigger and repeat the test, session 2 returns the expected
result.

#2Bharath Rupireddy
bharath.rupireddyforpostgres@gmail.com
In reply to: PG Bug reporting form (#1)
Re: BUG #19536: UPDATE RETURNING OLD value is stale after concurrent update when table has a BEFORE UPDATE trigger

Hi,

On Sat, Jun 27, 2026 at 5:54 AM PG Bug reporting form
<noreply@postgresql.org> wrote:

The following bug has been logged on the website:

Bug reference: 19536
Logged by: Jonas Boberg
Email address: bobergj@gmail.com
PostgreSQL version: 18.4
Operating system: Docker image postgres:18.4-alpine3.23
Description:

Thanks for reporting the bug and a reproducer!

In an UPDATE ... RETURNING OLD under isolation level READ COMMITTED, when
the table has a BEFORE UPDATE trigger, the OLD value is stale after a
concurrent update.

SELECT version();

PostgreSQL 18.4 on aarch64-unknown-linux-musl, compiled by gcc (Alpine

15.2.0) 15.2.0, 64-bit

## How to reproduce

Setup:
-------
CREATE TABLE t (
id int PRIMARY KEY,
n int NOT NULL
);

CREATE FUNCTION before_update_noop()
RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
RETURN NEW;
END;
$$;

CREATE TRIGGER t_before_update
BEFORE UPDATE ON t
FOR EACH ROW EXECUTE FUNCTION before_update_noop();

INSERT INTO t VALUES (1, 7);
-----

psql session 1:
------
BEGIN ISOLATION LEVEL READ COMMITTED;

UPDATE t
SET n = n + 10
WHERE id = 1;
-- do not commit, hold the row lock
------
Leave session 1 open

psql session 2:
-----
BEGIN ISOLATION LEVEL READ COMMITTED;
UPDATE t
SET n = n + 1
WHERE id = 1
RETURNING
OLD.n AS old_n,
NEW.n AS new_n,
NEW.n - 1 AS expected_old;
----
Session 2 blocks. Now commit in session 1:
----
COMMIT;
----

## Actual result

Session 2 outputs:
old_n | new_n | expected_old
-------+-------+--------------
7 | 18 | 17
(1 row)

Here OLD.n is the pre-concurrent update value, while NEW.n is the value
written by the concurrent update.

## Expected result

old_n | new_n | expected_old
-------+-------+--------------
17 | 18 | 17

If I drop the trigger and repeat the test, session 2 returns the expected
result.

I reproduced it on HEAD.

Backend 1 stores its txn-id in the old row's xmax (ctid=(0,1), n=7)
and is still in progress.

Backend 2 tries to lock the same row, sees that xmax holds an
in-progress txn, and waits (ExecUpdate -> ExecUpdatePrologue ->
ExecBRUpdateTriggers -> GetTupleForTrigger -> table_tuple_lock ->
heap_lock_tuple -> XactLockTableWait).

When backend 1 commits, backend 2 wakes up. Because the lock was
requested with TUPLE_LOCK_FLAG_FIND_LAST_VERSION (set under READ
COMMITTED in GetTupleForTrigger), heap_lock_tuple walks the
row-version chain to the latest version and advances tupleid from
(ctid=(0,1), n=7) to (ctid=(0,2), n=17).

In ExecUpdate after ExecUpdatePrologue, tupleid now points at the
latest version (ctid=(0,2), n=17) and the lock is already held, so
table_tuple_update has nothing to wait on and returns TM_Ok. But the
oldSlot - the slot RETURNING clause reads from - is only refreshed on
the TM_Updated path. Since we got TM_Ok, oldSlot still holds the value
captured before waiting (ctid=(0,1), n=7). NEW is correct (ctid=(0,3),
n=18).

In short: with a BEFORE UPDATE trigger, the trigger's tuple lock
advances tupleid to the concurrently-updated row version, so the later
table_tuple_update returns TM_Ok instead of TM_Updated. Since oldSlot
is only refreshed on the TM_Updated path, the RETURNING clause keeps
the pre-wait value (ctid=(0,1), n=7) instead of the actual
concurrently-updated value (ctid=(0,2), n=17). Without the trigger,
the wait happens inside table_tuple_update itself, which returns
TM_Updated and correctly refreshes oldSlot.

I'm adding Dean Rasheed (commit 80feb727c86 v18 - Add OLD/NEW support
to RETURNING in DML queries.) for additional inputs.

--
Bharath Rupireddy
Amazon Web Services: https://aws.amazon.com

#3Dean Rasheed
dean.a.rasheed@gmail.com
In reply to: Bharath Rupireddy (#2)
Re: BUG #19536: UPDATE RETURNING OLD value is stale after concurrent update when table has a BEFORE UPDATE trigger

On Sat, 27 Jun 2026 at 20:22, Bharath Rupireddy
<bharath.rupireddyforpostgres@gmail.com> wrote:

In short: with a BEFORE UPDATE trigger, the trigger's tuple lock
advances tupleid to the concurrently-updated row version, so the later
table_tuple_update returns TM_Ok instead of TM_Updated. Since oldSlot
is only refreshed on the TM_Updated path, the RETURNING clause keeps
the pre-wait value (ctid=(0,1), n=7) instead of the actual
concurrently-updated value (ctid=(0,2), n=17). Without the trigger,
the wait happens inside table_tuple_update itself, which returns
TM_Updated and correctly refreshes oldSlot.

Yes, that analysis seems correct.

This doesn't affect DELETE, because the DELETE code always fetches the
most recent version of the old tuple just before processing the
RETURNING clause. Similarly, it doesn't affect a cross-partition
UPDATE, which does a DELETE followed by an INSERT.

It also doesn't affect MERGE UPDATE/DELETE, because that does its own
EPQ handling, rather than relying on the trigger code to do it (see
9321c79c86e).

So I think we just need something like the attached.

Regards,
Dean

Attachments:

fix-returning-old-with-before-update-trigger.patchtext/x-patch; charset=US-ASCII; name=fix-returning-old-with-before-update-trigger.patchDownload+120-77
#4Bharath Rupireddy
bharath.rupireddyforpostgres@gmail.com
In reply to: Dean Rasheed (#3)
Re: BUG #19536: UPDATE RETURNING OLD value is stale after concurrent update when table has a BEFORE UPDATE trigger

Hi,

On Sun, Jun 28, 2026 at 2:59 AM Dean Rasheed <dean.a.rasheed@gmail.com> wrote:

On Sat, 27 Jun 2026 at 20:22, Bharath Rupireddy
<bharath.rupireddyforpostgres@gmail.com> wrote:

In short: with a BEFORE UPDATE trigger, the trigger's tuple lock
advances tupleid to the concurrently-updated row version, so the later
table_tuple_update returns TM_Ok instead of TM_Updated. Since oldSlot
is only refreshed on the TM_Updated path, the RETURNING clause keeps
the pre-wait value (ctid=(0,1), n=7) instead of the actual
concurrently-updated value (ctid=(0,2), n=17). Without the trigger,
the wait happens inside table_tuple_update itself, which returns
TM_Updated and correctly refreshes oldSlot.

Yes, that analysis seems correct.

Thanks for the patch! This matches the fix I had in mind after my
analysis as well.

A couple of comments:

1/ I think the refetch (which is only needed when RETURNING references
the OLD tuple) seems unnecessary. Fetching the row version comes with
an additional buffer pool lookup (possibly a cache miss), a buffer
pin, etc. Could we instead reuse the tuple that GetTupleForTrigger has
already fetched? I might be overthinking the performance overhead
here, but on busy production systems we can't ignore these costs.
Others may have a different take.

2/
+ context->tmfd.traversed = false;
if (!ExecUpdatePrologue(context, resultRelInfo, tupleid, oldtuple,
slot, NULL))
return NULL;

Setting traversed to false here seems a bit off. Is this needed
because the caller doesn't initialize ModifyTableContext properly?

3/

This doesn't affect DELETE, because the DELETE code always fetches the
most recent version of the old tuple just before processing the
RETURNING clause.

Similarly, it doesn't affect a cross-partition
UPDATE, which does a DELETE followed by an INSERT.

It also doesn't affect MERGE UPDATE/DELETE, because that does its own
EPQ handling, rather than relying on the trigger code to do it (see
9321c79c86e).

I haven't checked these paths in depth, but I think it's worth adding
tests for all of them.

4/
+ if (context->tmfd.traversed)
+ {
+ if (!table_tuple_fetch_row_version(resultRelInfo->ri_RelationDesc,
+    tupleid,
+    SnapshotAny,
+    oldSlot))
+ elog(ERROR, "failed to fetch tuple being updated");
+ }

Could we add some context to the error message, e.g., mentioning that
this happens after processing the BEFORE UPDATE row triggers? That
would make it easier to distinguish from the other similar ones.

--
Bharath Rupireddy
Amazon Web Services: https://aws.amazon.com

#5Dean Rasheed
dean.a.rasheed@gmail.com
In reply to: Bharath Rupireddy (#4)
Re: BUG #19536: UPDATE RETURNING OLD value is stale after concurrent update when table has a BEFORE UPDATE trigger

On Mon, 29 Jun 2026 at 00:35, Bharath Rupireddy
<bharath.rupireddyforpostgres@gmail.com> wrote:

Thanks for the patch! This matches the fix I had in mind after my
analysis as well.

A couple of comments:

1/ I think the refetch (which is only needed when RETURNING references
the OLD tuple) seems unnecessary. Fetching the row version comes with
an additional buffer pool lookup (possibly a cache miss), a buffer
pin, etc. Could we instead reuse the tuple that GetTupleForTrigger has
already fetched? I might be overthinking the performance overhead
here, but on busy production systems we can't ignore these costs.
Others may have a different take.

Thanks for reviewing.

I did think of that, but I was uneasy about copying the trigger's old
tuple after trigger invocation, in case the trigger scribbled on it
somehow. I think that's not possible, but it just didn't seem good
practice to rely on it being unchanged. ExecBRUpdateTriggers() could
copy and return oldslot before invoking the triggers, but that would
require changing its API. In the end, I think that in the context of
having just waited for the other session to commit or rollback, and
then executing trigger code, the cost of refetching the tuple ought to
be negligible, so trying to reuse the tuple that the trigger code
fetched feels like an unnecessary optimisation.

Actually, I think a neater long-term solution would be to move all the
EPQ rechecking and re-computing of the new tuple out of trigger.c, and
let the caller deal with it, as we do for the MERGE code, or perhaps
even make the caller responsible for locking the old tuple, but that's
beyond the scope of this kind of bug fix.

2/
+ context->tmfd.traversed = false;
if (!ExecUpdatePrologue(context, resultRelInfo, tupleid, oldtuple,
slot, NULL))
return NULL;

Setting traversed to false here seems a bit off. Is this needed
because the caller doesn't initialize ModifyTableContext properly?

Yes, that's right. The caller does not initialise context->tmfd at
all, and ExecUpdatePrologue() doesn't touch it if there are no
triggers. The other way to do it would have been to make
ExecUpdatePrologue() always set it, but I prefer this way, since it's
more obvious that ExecUpdate() is responding to ExecUpdatePrologue()
changing it (kind-of like setting errno to 0 before calling a function
that might set it to a non-zero value).

3/

This doesn't affect DELETE, because the DELETE code always fetches the
most recent version of the old tuple just before processing the
RETURNING clause.

Similarly, it doesn't affect a cross-partition
UPDATE, which does a DELETE followed by an INSERT.

It also doesn't affect MERGE UPDATE/DELETE, because that does its own
EPQ handling, rather than relying on the trigger code to do it (see
9321c79c86e).

I haven't checked these paths in depth, but I think it's worth adding
tests for all of them.

OK, I've added a bunch of tests for those cases. As expected, they
pass even without the code changes, but it's good to confirm that.

4/
+ if (context->tmfd.traversed)
+ {
+ if (!table_tuple_fetch_row_version(resultRelInfo->ri_RelationDesc,
+    tupleid,
+    SnapshotAny,
+    oldSlot))
+ elog(ERROR, "failed to fetch tuple being updated");
+ }

Could we add some context to the error message, e.g., mentioning that
this happens after processing the BEFORE UPDATE row triggers? That
would make it easier to distinguish from the other similar ones.

I'm pretty sure this is a can't-happen error condition, but OK.

Regards,
Dean

Attachments:

v2-fix-returning-old-with-before-update-trigger.patchtext/x-patch; charset=US-ASCII; name=v2-fix-returning-old-with-before-update-trigger.patchDownload+333-117
#6Bharath Rupireddy
bharath.rupireddyforpostgres@gmail.com
In reply to: Dean Rasheed (#5)
Re: BUG #19536: UPDATE RETURNING OLD value is stale after concurrent update when table has a BEFORE UPDATE trigger

Hi,

On Tue, Jun 30, 2026 at 1:35 AM Dean Rasheed <dean.a.rasheed@gmail.com> wrote:

I did think of that, but I was uneasy about copying the trigger's old
tuple after trigger invocation, in case the trigger scribbled on it
somehow.

Fair argument. I'm convinced.

Actually, I think a neater long-term solution would be to move all the
EPQ rechecking and re-computing of the new tuple out of trigger.c, and
let the caller deal with it, as we do for the MERGE code

Agreed.

Yes, that's right. The caller does not initialise context->tmfd at
all, and ExecUpdatePrologue() doesn't touch it if there are no
triggers.

That works for me.

OK, I've added a bunch of tests for those cases. As expected, they
pass even without the code changes, but it's good to confirm that.

Nice, thank you for adding those.

Could we add some context to the error message, e.g., mentioning that
this happens after processing the BEFORE UPDATE row triggers? That
would make it easier to distinguish from the other similar ones.

I'm pretty sure this is a can't-happen error condition, but OK.

Thanks.

Sorry for the late response. I took a look at the v2 patch, tested it
locally, and it looks good to me. I think we should backpatch this fix
down to PG18 (commit 80feb727c86).

--
Bharath Rupireddy
Amazon Web Services: https://aws.amazon.com

#7Dean Rasheed
dean.a.rasheed@gmail.com
In reply to: Bharath Rupireddy (#6)
Re: BUG #19536: UPDATE RETURNING OLD value is stale after concurrent update when table has a BEFORE UPDATE trigger

On Tue, 7 Jul 2026 at 08:38, Bharath Rupireddy
<bharath.rupireddyforpostgres@gmail.com> wrote:

Sorry for the late response. I took a look at the v2 patch, tested it
locally, and it looks good to me. I think we should backpatch this fix
down to PG18 (commit 80feb727c86).

Thanks for reviewing.

Pushed and back-patched to v18.

Regards,
Dean