Three write paths store now() at time zone 'utc' into a timestamp with time zone column. That expression strips the offset to produce a naive timestamp holding UTC wall-clock time, and assigning a naive timestamp to a timestamptz re-interprets it in the session's TimeZone. On any database whose TimeZone is not UTC the stored instant is wrong by the UTC offset — it is a genuinely different point in time, not a display artifact.
The matching INSERT defaults ((now()), (transaction_timestamp())) are correct, so a row is stamped correctly on insert and then jumps by the offset the first time it goes through one of these paths.
Reproduction
create database tz_probe;
alter database tz_probe set timezone='Europe/Berlin';
\c tz_probe
create table probe(seq bigserial primary key, ts timestamp with time zone not null default (now()));
insert into probe(ts) values ((now() at time zone 'utc')); -- what the write paths below do
insert into probe(ts) values (default); -- what the column DEFAULT does
select seq, ts, now() as server_now, now() - ts as reads_this_far_in_the_past from probe order by seq;
seq | ts | server_now | reads_this_far_in_the_past
-----+-------------------------------+-------------------------------+----------------------------
1 | 2026-08-03 13:41:09.340771+02 | 2026-08-03 15:41:09.340771+02 | 02:00:00
2 | 2026-08-03 15:41:09.340771+02 | 2026-08-03 15:41:09.340771+02 | 00:00:00
Two hours in the past on a UTC+2 database. West of UTC it skews the other way (America/Chicago → five hours in the future).
Affected sites
| Site |
Column |
Impact |
LastModifiedColumn.cs:27 — WriteMetadataInUpdateStatement |
mt_last_modified (timestamp with time zone, default (transaction_timestamp())) |
Every document UPDATE. Broadest blast radius: IDocumentMetadata.LastModified, ModifiedSince/ModifiedBefore queries, and anything auditing document freshness. Inserted rows are correct; the value shifts on first update. |
QuickAppendEventFunction.cs:69 |
mt_events.timestamp (timestamp with time zone) |
AppendMode.Quick only. IEvent.Timestamp, AggregateStreamAsync(streamId, timestamp:), any time-bounded event query or archiving-by-age policy. mt_streams.timestamp in the same function correctly uses bare now(). |
ReplaceEventOperation.cs:47 |
mt_events.timestamp |
Event-data-protection rewrites re-stamp the event with a skewed timestamp regardless of append mode. |
EventAppendMode.Rich (the default) and QuickWithServerTimestamps bind a client DateTimeOffset, which is unambiguous — those paths look correct.
Why it is reachable
Neither Marten nor Npgsql pins the session TimeZone. Npgsql exposes an opt-in Timezone connection-string parameter; when it is not supplied the session inherits the server/database TimeZone GUC. Managed Postgres offerings commonly default to UTC, but on-prem installs and initdb on a non-UTC host frequently do not, and ALTER DATABASE … SET TimeZone is a normal thing for an operator to do.
I have verified the SQL semantics and the code sites above, but have not run an end-to-end Marten test against a non-UTC-TimeZone database — worth confirming before treating the fix as mechanical, in case something upstream pins the session timezone that I have missed.
Suggested fix
Replace all three with bare now() (or transaction_timestamp(), matching the corresponding column DEFAULT). No schema migration is needed and no round-trip behavior changes on a UTC database, so this should be safe to backport.
Worth pinning with a regression test that runs against a database with a non-UTC TimeZone — asserting that a document's LastModified after an update, and a Quick-appended IEvent.Timestamp, are both within a small window of now().
Why this surfaced
Noticed while reviewing #5109, which proposes computing a stuck-gap age as transaction_timestamp() - mt_events.timestamp to bound the SkipStaleGapsDespiteLiveTransactionsAfter cap. That arithmetic inherits this skew directly: on a UTC+2 database in Quick append mode every gap would read two hours older than it is, collapsing a configured cap into "skip as soon as StaleSequenceThreshold passes" and dropping events from live appends. That PR is a separate discussion (see #5108, #5125), but it should not land on top of this column until the column is trustworthy.
Three write paths store
now() at time zone 'utc'into atimestamp with time zonecolumn. That expression strips the offset to produce a naive timestamp holding UTC wall-clock time, and assigning a naive timestamp to atimestamptzre-interprets it in the session'sTimeZone. On any database whoseTimeZoneis not UTC the stored instant is wrong by the UTC offset — it is a genuinely different point in time, not a display artifact.The matching INSERT defaults (
(now()),(transaction_timestamp())) are correct, so a row is stamped correctly on insert and then jumps by the offset the first time it goes through one of these paths.Reproduction
Two hours in the past on a UTC+2 database. West of UTC it skews the other way (
America/Chicago→ five hours in the future).Affected sites
LastModifiedColumn.cs:27—WriteMetadataInUpdateStatementmt_last_modified(timestamp with time zone, default(transaction_timestamp()))IDocumentMetadata.LastModified,ModifiedSince/ModifiedBeforequeries, and anything auditing document freshness. Inserted rows are correct; the value shifts on first update.QuickAppendEventFunction.cs:69mt_events.timestamp(timestamp with time zone)AppendMode.Quickonly.IEvent.Timestamp,AggregateStreamAsync(streamId, timestamp:), any time-bounded event query or archiving-by-age policy.mt_streams.timestampin the same function correctly uses barenow().ReplaceEventOperation.cs:47mt_events.timestampEventAppendMode.Rich(the default) andQuickWithServerTimestampsbind a clientDateTimeOffset, which is unambiguous — those paths look correct.Why it is reachable
Neither Marten nor Npgsql pins the session
TimeZone. Npgsql exposes an opt-inTimezoneconnection-string parameter; when it is not supplied the session inherits the server/databaseTimeZoneGUC. Managed Postgres offerings commonly default to UTC, but on-prem installs andinitdbon a non-UTC host frequently do not, andALTER DATABASE … SET TimeZoneis a normal thing for an operator to do.I have verified the SQL semantics and the code sites above, but have not run an end-to-end Marten test against a non-UTC-
TimeZonedatabase — worth confirming before treating the fix as mechanical, in case something upstream pins the session timezone that I have missed.Suggested fix
Replace all three with bare
now()(ortransaction_timestamp(), matching the corresponding column DEFAULT). No schema migration is needed and no round-trip behavior changes on a UTC database, so this should be safe to backport.Worth pinning with a regression test that runs against a database with a non-UTC
TimeZone— asserting that a document'sLastModifiedafter an update, and a Quick-appendedIEvent.Timestamp, are both within a small window ofnow().Why this surfaced
Noticed while reviewing #5109, which proposes computing a stuck-gap age as
transaction_timestamp() - mt_events.timestampto bound theSkipStaleGapsDespiteLiveTransactionsAftercap. That arithmetic inherits this skew directly: on a UTC+2 database in Quick append mode every gap would read two hours older than it is, collapsing a configured cap into "skip as soon asStaleSequenceThresholdpasses" and dropping events from live appends. That PR is a separate discussion (see #5108, #5125), but it should not land on top of this column until the column is trustworthy.