@PeterVandivier rightly points out that the immutability comes from TIMESTAMPTZ
, but it also comes from the interval. In general, intervals are treated as mutable (it appears, even with a TIMESTAMP
without time zone).
The problem comes from the fact that some intervals are time-zone dependent, in particular days: you get days of 23 and 25 hours around the Daylight Saving Time changes.
Here, you're working with minutes, so you can work around this by making everything immutable:
CREATE TABLE appt (
appt_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
minute_duration INTEGER NOT NULL,
start_datetime TIMESTAMPTZ NOT NULL,
end_datetime TIMESTAMPTZ GENERATED ALWAYS AS
((start_datetime AT TIME ZONE 'UTC' + (minute_duration * '1 minute'::INTERVAL)) AT TIME ZONE 'UTC') STORED
);
To explain this a bit more closely:
(start_datetime AT TIME ZONE 'UTC'
+ (minute_duration * '1 minute'::INTERVAL)
) AT TIME ZONE 'UTC'
start_datetime AT TIME ZONE 'UTC'
converts your TIMESTAMPTZ
into its UTC TIMESTAMP
equivalent in an immutable way.
minute_duration * '1 minute'::INTERVAL
multiplies the number of minutes (immutable) by a 1-minute interval (also immutable). This trick is necessary, because the (minute_duration || ' minutes')::INTERVAL
doesn't seem to detect that the string will necessarily be a number and the word ' minutes', making it immutable (an arbitrary string as as '1 day 1 hour'
would not necessarily be immutable).
Convert it back to a TIMESTAMPTZ
with AT TIME ZONE 'UTC'
.
Note that the end result will not necessarily be in UTC, but in whatever time zone you've chosen with your session (as you would expect from any TIMESTAMPTZ
column).
Indeed, PostgreSQL's TIMESTAMP WITH TIME ZONE
doesn't actually store any time zone information: it just displays and makes calculations with the time zone you're currently using.
To expand a little bit on why TIMESTAMPTZ
makes this mutable, this is because it makes the calculations based on that timestamp dependent upon the time zone currently set for the transaction/session.
Here is an example:
CREATE TABLE test1 (
id INTEGER PRIMARY KEY,
original_ts TIMESTAMPTZ,
added_ts TIMESTAMPTZ
);
SET TIME ZONE 'Africa/Douala'; -- No Daylight Saving Time change
INSERT INTO test1(id, original_ts, added_ts)
VALUES (1,
'2025-03-29T15:00:00Z'::TIMESTAMPTZ,
'2025-03-29T15:00:00Z'::TIMESTAMPTZ + '1 day'::INTERVAL);
SET TIME ZONE 'Europe/London'; -- Daylight Saving Time change
INSERT INTO test1(id, original_ts, added_ts)
VALUES (2,
'2025-03-29T15:00:00Z'::TIMESTAMPTZ,
'2025-03-29T15:00:00Z'::TIMESTAMPTZ + '1 day'::INTERVAL);
SET TIME ZONE 'Europe/Paris';
SELECT * FROM test1;
In both cases, I'm adding "1 day" to 2025-03-29T15:00:00Z
(15:00 at UTC). However,:
- the Africa/Douala time zone doesn't have DST change, so "+1 day" is always going to be "+24 hours",
- the Europe/London time zone changes from UTC to UTC+1 on the 2025-03-30 at 1AM, so "+1 day" means "+23 hours" on that day.
As a result, you get this:
id |
original_ts |
added_ts |
1 |
2025-03-29 16:00:00+01 |
2025-03-30 17:00:00+02 |
2 |
2025-03-29 16:00:00+01 |
2025-03-30 16:00:00+02 |
This is where the mutability comes from: in general, operations on TIMESTAMP WITH TIME ZONE
vary depending on what you have set for the session/transaction.
Coming back to the fact that the time zone doesn't really affect storage, but only display and operations, if I change the current time zone, I'll get the same instants in time presented with different time zones:
SET TIME ZONE 'Europe/Bucharest';
SELECT * FROM test1;
id |
original_ts |
added_ts |
1 |
2025-03-29 17:00:00+02 |
2025-03-30 18:00:00+03 |
2 |
2025-03-29 17:00:00+02 |
2025-03-30 17:00:00+03 |
If I now repeat the same experiment with plain TIMESTAMP
(without time zone), I get this (which does not vary based on the current time zone setting):
CREATE TABLE test2 (
id INTEGER PRIMARY KEY,
original_ts TIMESTAMP,
added_ts TIMESTAMP
);
SET TIME ZONE 'Africa/Douala';
INSERT INTO test2(id, original_ts, added_ts)
VALUES (1,
'2025-03-29T15:00:00'::TIMESTAMP,
'2025-03-29T15:00:00'::TIMESTAMP + '1 day'::INTERVAL);
SET TIME ZONE 'Europe/London';
INSERT INTO test2(id, original_ts, added_ts)
VALUES (2,
'2025-03-29T15:00:00'::TIMESTAMP,
'2025-03-29T15:00:00'::TIMESTAMP + '1 day'::INTERVAL);
SET TIME ZONE 'Europe/Bucharest';
SELECT * FROM test2;
id |
original_ts |
added_ts |
1 |
2025-03-29 15:00:00 |
2025-03-30 15:00:00 |
2 |
2025-03-29 15:00:00 |
2025-03-30 15:00:00 |