Describe the bug
Two related type-level problems with TIMESTAMP WITH TIME ZONE. They share a root cause and I think they should be decided together.
1. TIMESTAMP WITH TIME ZONE can resolve to a timezone-naive Arrow type.
With the default configuration (datafusion.execution.time_zone unset):
SELECT arrow_typeof('2024-01-01T12:00:00Z'::timestamptz) AS type,
'2024-01-01T12:00:00Z'::timestamptz AS value;
+---------------+---------------------+
| type | value |
+---------------+---------------------+
| Timestamp(ns) | 2024-01-01T12:00:00 |
+---------------+---------------------+
The Z is accepted and then discarded. A SQL type whose name is "with time zone" produced an Arrow type with no time zone.
2. Casting an already timezone-aware value to timestamptz overwrites its timezone.
SET datafusion.execution.time_zone = 'UTC';
SELECT arrow_typeof((t AT TIME ZONE 'Europe/Brussels')::timestamptz) FROM …;
-- Timestamp(ns, "UTC") -- the Brussels annotation is gone
This is the root cause of #13962: date_trunc('day', (t AT TIME ZONE 'Europe/Brussels')::timestamptz) groups by UTC day while naming Brussels. Remove the ::timestamptz and the same query is correct. It is also the same defect family as #23841 (to_timestamp discarding an aware input's timezone).
To Reproduce
Both snippets above, on DataFusion 55.0.0 (da89c7c85b).
Expected behavior
Two invariants, which I think are worth stating explicitly whatever the fix:
TIMESTAMP WITH TIME ZONE never resolves to Timestamp(_, None). When the session time zone is unset it should fall back to a fixed zone rather than to naive.
CAST(x AS TIMESTAMPTZ) where x is already aware preserves x's own timezone.
Additional context
This is a traceable side effect, not a deliberate design. The default changed from "+00:00" to None in #18359 (DataFusion 51.0.0). That change was deliberate and well reasoned — it followed #18017 making now()/current_date()/current_time() read the session timezone, and #18204 / #18081 proposed making the config an Option.
But the whole of its effect on the SQL type system was this edit in datafusion/sql/src/planner.rs:
- Some(self.context_provider.options().execution.time_zone.clone())
+ self.context_provider.options().execution.time_zone.clone()
a mechanical unwrap because the field's type changed. The comment two lines above it is still in the tree today and still says:
// OUTPUT: [ArrowDataType] Timestamp<TimeUnit, Some(Time Zone)>
Neither the PR body, nor any of the three driver issues, nor the upgrade-guide note mentions timestamptz or TIMESTAMP WITH TIME ZONE — the note frames the change purely as "to better support using the default timezone in scalar UDF functions such as now, current_date, current_time, and to_timestamp". The type-system consequence was never discussed.
PostgreSQL and DuckDB (measured, PG 17.11 / DuckDB 1.5.2) both default TimeZone to the machine's zone, never unset, so timestamptz is always aware in both. Their type is also invariant to the session zone — only rendering changes:
PG SET TimeZone='UTC'; pg_typeof -> timestamp with time zone, 12:00:00+00
SET TimeZone='Europe/Brussels'; pg_typeof -> timestamp with time zone, 13:00:00+01
DuckDB same in both
So invariant 1 has two reference implementations. Invariant 2 does not, and this is worth being explicit about: neither system can represent a per-value timezone at all, so neither can arbitrate. Worse, copying PostgreSQL literally gives you the current behaviour — in PG, tstz::timestamptz renders in the session zone because the value never carried one. Invariant 2 has to be argued on DataFusion's own terms: Arrow puts the zone in the value, so an operation not asked to change it shouldn't. AT TIME ZONE 'Z' already means "convert to Z" and arrow_cast already means "give me exactly this Arrow type", so ::timestamptz does not need to be a third way to change a timezone.
Note that simply changing the default would not fix #13962. With the default set to UTC, invariant 1 holds but the cast still relabels Brussels to UTC and date_trunc still groups by the wrong day. Invariant 2 is the one that fixes it. Changing the default is a separate question, and would flip now()'s type a second time in five releases (aware ≤50 → naive 51+ → aware again), so it deserves its own discussion.
Related: #13962 (user-visible symptom), #23841 (same defect in to_timestamp), #13212 (which timezone an implicit coercion should pick), #21116 (TimestampWithOffset, the direction the ecosystem is moving).
Describe the bug
Two related type-level problems with
TIMESTAMP WITH TIME ZONE. They share a root cause and I think they should be decided together.1.
TIMESTAMP WITH TIME ZONEcan resolve to a timezone-naive Arrow type.With the default configuration (
datafusion.execution.time_zoneunset):The
Zis accepted and then discarded. A SQL type whose name is "with time zone" produced an Arrow type with no time zone.2. Casting an already timezone-aware value to
timestamptzoverwrites its timezone.This is the root cause of #13962:
date_trunc('day', (t AT TIME ZONE 'Europe/Brussels')::timestamptz)groups by UTC day while naming Brussels. Remove the::timestamptzand the same query is correct. It is also the same defect family as #23841 (to_timestampdiscarding an aware input's timezone).To Reproduce
Both snippets above, on DataFusion 55.0.0 (
da89c7c85b).Expected behavior
Two invariants, which I think are worth stating explicitly whatever the fix:
TIMESTAMP WITH TIME ZONEnever resolves toTimestamp(_, None). When the session time zone is unset it should fall back to a fixed zone rather than to naive.CAST(x AS TIMESTAMPTZ)wherexis already aware preservesx's own timezone.Additional context
This is a traceable side effect, not a deliberate design. The default changed from
"+00:00"toNonein #18359 (DataFusion 51.0.0). That change was deliberate and well reasoned — it followed #18017 makingnow()/current_date()/current_time()read the session timezone, and #18204 / #18081 proposed making the config anOption.But the whole of its effect on the SQL type system was this edit in
datafusion/sql/src/planner.rs:a mechanical unwrap because the field's type changed. The comment two lines above it is still in the tree today and still says:
// OUTPUT: [ArrowDataType] Timestamp<TimeUnit, Some(Time Zone)>Neither the PR body, nor any of the three driver issues, nor the upgrade-guide note mentions
timestamptzorTIMESTAMP WITH TIME ZONE— the note frames the change purely as "to better support using the default timezone in scalar UDF functions such asnow,current_date,current_time, andto_timestamp". The type-system consequence was never discussed.PostgreSQL and DuckDB (measured, PG 17.11 / DuckDB 1.5.2) both default
TimeZoneto the machine's zone, never unset, sotimestamptzis always aware in both. Their type is also invariant to the session zone — only rendering changes:So invariant 1 has two reference implementations. Invariant 2 does not, and this is worth being explicit about: neither system can represent a per-value timezone at all, so neither can arbitrate. Worse, copying PostgreSQL literally gives you the current behaviour — in PG,
tstz::timestamptzrenders in the session zone because the value never carried one. Invariant 2 has to be argued on DataFusion's own terms: Arrow puts the zone in the value, so an operation not asked to change it shouldn't.AT TIME ZONE 'Z'already means "convert to Z" andarrow_castalready means "give me exactly this Arrow type", so::timestamptzdoes not need to be a third way to change a timezone.Note that simply changing the default would not fix #13962. With the default set to UTC, invariant 1 holds but the cast still relabels Brussels to UTC and
date_truncstill groups by the wrong day. Invariant 2 is the one that fixes it. Changing the default is a separate question, and would flipnow()'s type a second time in five releases (aware ≤50 → naive 51+ → aware again), so it deserves its own discussion.Related: #13962 (user-visible symptom), #23841 (same defect in
to_timestamp), #13212 (which timezone an implicit coercion should pick), #21116 (TimestampWithOffset, the direction the ecosystem is moving).