Skip to content

test: characterization suite for timestamps with time zone - #25175

Open
adriangb wants to merge 4 commits into
apache:mainfrom
pydantic:test-timezone-characterization-datetime
Open

test: characterization suite for timestamps with time zone#25175
adriangb wants to merge 4 commits into
apache:mainfrom
pydantic:test-timezone-characterization-datetime

Conversation

@adriangb

@adriangb adriangb commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

This PR closes no issue and fixes no bug. It adds tests only.

Related to these issues. For each issue, the file records what DataFusion does today, so a fix shows up as a test diff:

These issues are also related, but no expectation in the file exercises them yet. The self-review comment on this PR gives a query for each:

Background: #10368, #10602, #13212.

Rationale for this change

Here is one behaviour that this file pins. day_denver has four rows at 00:00, 06:00, 12:00 and 18:00 UTC on 2024-07-01, with the type Timestamp(ns, "America/Denver"):

SELECT ts, date_bin(INTERVAL '1 day', ts), date_trunc('day', ts) FROM day_denver;
ts date_bin(INTERVAL '1 day', ts) date_trunc('day', ts)
2024-06-30T18:00:00-06:00 2024-06-30T18:00:00-06:00 2024-06-30T00:00:00-06:00
2024-07-01T00:00:00-06:00 2024-06-30T18:00:00-06:00 2024-07-01T00:00:00-06:00
2024-07-01T06:00:00-06:00 2024-06-30T18:00:00-06:00 2024-07-01T00:00:00-06:00
2024-07-01T12:00:00-06:00 2024-06-30T18:00:00-06:00 2024-07-01T00:00:00-06:00

A user asks both functions for the day of each row, and gets two different answers. date_bin returns the UTC day, shown as 18:00 local time. date_trunc returns the local Denver day. So a GROUP BY on each function gives different groups. The file pins this and links #25167. When a fix lands, these rows change, and the diff shows the new contract.

What this file is for. It records what DataFusion does today with timestamps that carry a time zone, bugs included. A change to that behaviour then makes a test fail, so a reviewer sees the change. Where today's answer is a bug or differs from PostgreSQL, a comment above the query says so and links the issue.

Why time zone bugs recur

  • The answer depends on several things at once: the session time zone, the time zone of the value, DST, and optimizer rewrites.
  • On main before this PR, the sqllogictest corpus has 40 SET datafusion.execution.time_zone statements, in 9 files. The largest file has 10.
  • The PostgreSQL differential harness (test_files/pg_compat/) has no time zone coverage.
  • So a change to time zone behaviour can merge with no test diff. A fix for one bug can also break another behaviour, and no test fails.

The companion PR #25164 checks the subset where DataFusion and PostgreSQL agree, against a real PostgreSQL. This PR pins everything else. So this file changes when a fix lands, by design.

What changes are included in this PR?

This PR adds one test file and changes no production code:
datafusion/sqllogictest/test_files/datetime/timestamps_timezone.slt (1553 lines, 18 sections).

Many cases run on a literal and also on a real CTAS column, because some behaviours occur only on columns.

Section Coverage
0-4 arrow_typeof and the value of ::timestamp, ::timestamptz, TIMESTAMP '...' and TIMESTAMP WITH TIME ZONE '...', with the session time zone unset, +00:00, +05:30, America/Denver and Europe/Brussels
5 AT TIME ZONE on naive and aware values, and the sign of '+05:30'
6 casts: naive to named zone, named zone to naive, named to other named, naive to fixed offset, named to fixed offset
7 round trips: naive to aware to naive, and aware to naive to aware
8 comparisons between aware and naive values, with EXPLAIN of the filter
9 date_bin, date_trunc, date_part and extract on aware values, and the date_bin origin drift
10 to_char, from_unixtime, to_unixtime, to_timestamp* and to_local_time
11 now, current_date, current_time and make_date under a session time zone
12 timestamp plus or minus an interval across both DST transitions, and '1 day' against '24 hours'
13-15 MIN, MAX, GROUP BY, ORDER BY, DISTINCT; joins on aware keys, mixed zones included; UNION, CASE, COALESCE and greatest across mixed zones
16 DST boundaries: a repeated local time and a local time that does not exist, in America/Denver and Europe/Brussels, by column cast, AT TIME ZONE, string literal and ::timestamptz
17 America/Phoenix (no DST) and Asia/Kolkata (half-hour offset)

How we made the expected output

  1. We generated the expected output with the sqllogictest --complete mode.
  2. We read each result, and rewrote the cases that did not test what they claimed.
  3. Where today's answer is a bug or a divergence, we added a comment with the issue link. The self-review comment on this PR lists the places where that comment is still absent or links the wrong issue.

Harness notes

  • The file runs in DataFusion only. Cross-engine claims in the comments come from separate PostgreSQL runs.
  • The Section 16 error expectations match only the stable Arrow error: ... tail of each message. The wrapper text above that tail changed in feat: include source field context in cast errors #24920, and these cases do not pin it.

Merge order: merge this PR after the fixes that it pins

We recommend that this PR merges after the fix PRs below. Then each fix PR stays small, and this PR records the fixed behaviour in one rebase. If this PR merges first, each fix PR must also edit this file.

We merged each PR into this branch at bafa661263 and ran this file:

PR (head) Change Result Update to this file after the PR merges
#25165 (3e42f8b102) AT TIME ZONE on an aware value returns a naive value 2 failures Section 5b, 2 queries: Timestamp(ns, "Europe/Brussels") 2024-01-15T13:00:00+01:00 becomes Timestamp(ns) 2024-01-15T13:00:00, and the Denver query changes the same way. Delete the DIVERGES FROM POSTGRESQL (type, not instant) comment.
#25163 (bdbfe1f8cd) date_part accepts timezone, timezone_hour and timezone_minute 2 failures Section 9: the two query error blocks succeed. Make them value queries, and delete the "GAP vs PostgreSQL" comment.
#25161 (fb72121aef) from_unixtime uses the session time zone pass Reword the Section 10 comment that says from_unixtime ignores the session time zone.
#25173 (aeb4122371) generate_series precision not run None. The file has no generate_series.
#25171 (095e6d3a31) a code comment and docs not run None. The PR changes no behaviour.
current main includes #24920 and #25182 pass None for the tests. See the license header note below.

In Section 5, only 5b applies AT TIME ZONE to an aware value. 5a, 5c, the composed idiom, Section 12 and Section 16 apply it to naive values, and #25165 does not change those.

License header. #25182 (merged 2026-09-11) adds *.slt to the license header check. The file now has # on header lines 8 and 10, as main expects (fixed in 6df7863bed).

What is the testing strategy for this PR?

This PR adds tests only. Checks at head bafa661263:

  • cargo test --profile ci -p datafusion-sqllogictest --test sqllogictests -- timestamps_timezone: pass.
  • The same command with current main merged in: pass.
  • CI "Check License Header": fails, see the merge-order section.

Field research: measured answers where a pinned behaviour diverges

DataFusion answers are the pinned expectations, except where the row says datafusion-cli. PostgreSQL answers come from postgres:15. DuckDB answers come from DuckDB 1.5.2. "Unset / UTC" means: the DataFusion session time zone is unset, and PostgreSQL and DuckDB use UTC.

Pinned behaviour (section) Session time zone DataFusion PostgreSQL 15 DuckDB 1.5.2
type of '2024-07-01 12:00:00Z'::timestamptz (0) unset / UTC Timestamp(ns) (naive) timestamp with time zone TIMESTAMP WITH TIME ZONE
TIMESTAMP '2024-07-01 12:00:00' AT TIME ZONE '+05:30' (5a) unset / UTC 2024-07-01T12:00:00+05:30 (06:30 UTC) 2024-07-01 17:30:00+00 error: Unknown TimeZone '+05:30'
aware 2024-07-01 12:00:00Z AT TIME ZONE 'Europe/Brussels' (5b) unset / UTC Timestamp(ns, "Europe/Brussels") 2024-07-01T14:00:00+02:00 timestamp without time zone 2024-07-01 14:00:00 TIMESTAMP 2024-07-01 14:00:00
('2024-07-01T18:00:00Z'::timestamptz AT TIME ZONE 'America/Denver')::timestamp (5) unset / UTC 2024-07-02T00:00:00 2024-07-01 12:00:00 2024-07-01 12:00:00
aware 18:00 UTC ::timestamp (6b) unset / America/Denver 2024-07-01T18:00:00 2024-07-01 12:00:00 2024-07-01 12:00:00
WHERE ts = '2024-07-01 12:00:00' on rows at 00, 06, 12, 18 UTC (8) unset / UTC the 18:00 UTC row the 12:00 UTC row the 12:00 UTC row
WHERE ts > '2024-07-01 06:00:00' on the same rows (8) unset / UTC 1 row 2 rows 2 rows
WHERE ts = '2024-07-01T06:00:00Z'::timestamptz on the same rows (8) unset / UTC the 12:00 UTC row the 06:00 UTC row the 06:00 UTC row
date_trunc('day', ts) for 00:00 UTC on a Denver value (9) unset / UTC 2024-06-30T00:00:00-06:00 2024-07-01 00:00:00+00 2024-07-01 00:00:00+00
date_bin(INTERVAL '1 day', ts) for the four Denver rows (9); PostgreSQL and DuckDB with origin 1970-01-01 00:00:00+00 America/Denver 2024-06-30T18:00:00-06:00 2024-06-30 18:00:00-06 2024-06-30 18:00:00-06 (time_bucket)
date_trunc('day', ...) and date_bin(INTERVAL '1 day', ...) for 2024-01-01 12:00:00+05:30 (9) Asia/Kolkata 2024-01-01T00:00:00+05:30 and 2024-01-01T05:30:00+05:30 2024-01-01 00:00:00+05:30 and 2024-01-01 05:30:00+05:30 2024-01-01 00:00:00+05:30 and 2024-01-01 05:30:00+05:30
date_bin with origin 2024-03-01T07:00:00Z, for 2024-03-11T12:30:00Z (9) America/Denver 2024-03-11T01:00:00-06:00 2024-03-11 01:00:00-06 2024-03-11 01:00:00-06
date_bin with a 1 month stride (9) unset / UTC returns a bin error: timestamps cannot be binned into intervals containing months or years not measured
date_part('timezone_hour', ts) in July (9) America/Denver error: Date part 'timezone_hour' not supported -6 -6
from_unixtime(1719792000); PostgreSQL and DuckDB to_timestamp(1719792000) (10) unset / UTC Timestamp(s) 2024-07-01T00:00:00 timestamp with time zone 2024-07-01 00:00:00+00 TIMESTAMP WITH TIME ZONE 2024-07-01 00:00:00+00
type of current_time (11) America/Denver / UTC Time64(ns) time with time zone not measured
(TIMESTAMP '2024-01-15 12:00:00' AT TIME ZONE 'America/Denver') = (TIMESTAMP '2024-07-01 12:00:00' AT TIME ZONE 'America/Denver') - INTERVAL '168 days' (12) unset / UTC true f false
the same expression America/Denver true (datafusion-cli) t true
'2024-11-03 01:30:00'::timestamptz, a repeated local time (16) America/Denver error: error computing timezone offset 2024-11-03 01:30:00-07 2024-11-03 01:30:00-07
'2024-03-10 02:30:00'::timestamptz, a local time that does not exist (16) America/Denver error: error computing timezone offset 2024-03-10 03:30:00-06 2024-03-10 03:30:00-06
'2024-10-27 02:30:00'::timestamptz (16) Europe/Brussels error: error computing timezone offset 2024-10-27 02:30:00+01 2024-10-27 02:30:00+01
'2024-03-31 02:30:00'::timestamptz (16) Europe/Brussels error: error computing timezone offset 2024-03-31 03:30:00+02 2024-03-31 03:30:00+02
aware 2024-07-01T12:00:00Z ::timestamp on a Kolkata value (17) unset / Asia/Kolkata 2024-07-01T12:00:00 2024-07-01 17:30:00 not measured

What the measurements show:

Are there any user-facing changes?

No. This PR adds one test file. It changes no production code, no public API and no behaviour.

🤖 Generated with Claude Code

Adds `test_files/datetime/timestamps_timezone.slt`, ~1500 lines in 18 labelled
sections that record what DataFusion actually does today with timezone-aware
timestamps.

DataFusion has a long tail of timezone correctness bugs, and the reason they
keep recurring is structural: almost nothing in the test suite pins the
behaviour down, so a change to timezone semantics can land without producing a
single test diff. `SET datafusion.execution.time_zone` appears roughly 40 times
in the whole sqllogictest corpus, nearly all of it in two files, and
DST-boundary dates are essentially absent.

This file does not change any behaviour. It writes the current behaviour down,
including the parts that are known wrong or known to disagree with PostgreSQL --
those carry a comment saying so and a link to the issue, rather than being
"fixed" here. When a fix lands, the diff is the specification of what changed.

Every case is exercised as a literal and, where it matters, as a real CTAS
column, since several of these behaviours only reproduce on columns. Sections
cover literal typing under five session zones, `AT TIME ZONE`, casts in all
four directions, round trips, tz-aware/tz-naive comparison (with `EXPLAIN` of
the rewritten filter), `date_bin`/`date_trunc`/`date_part`, `to_char` /
`to_local_time` / `to_timestamp*` / `from_unixtime`, `now`/`current_date`,
interval arithmetic across both DST transitions, aggregates, joins and
coercion, the US and EU DST boundaries by all four routes into a named zone,
and a no-DST zone plus a half-hour zone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Section 9 already had a `date_bin(..., origin)` case, but all of its data sits
inside a single July day, so it never crosses a transition and the drift is
invisible.

Adds a case whose two rows are the same local time of day on either side of the
America/Denver spring-forward transition, with the origin chosen so bins land
on local midnight. The first row does; the second lands on 01:00 local. That is
the bug in apache#25168 -- `date_bin`
steps a fixed number of nanoseconds from a fixed instant, so it cannot track a
local day that is 23 or 25 hours long.

`date_trunc` is asserted alongside as a control: it gets both rows right, so
the drift is a property of the origin arithmetic rather than of the data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Upstream apache#24920 (merged 2026-09-06)
adds field context to cast errors. The DST-boundary section pinned the full
error chain, so 16 expectations started to fail once CI merged this PR onto
current main -- 10 for America/Denver and 6 for Europe/Brussels:

    expected: DataFusion error: Arrow error: Cast error: Cannot cast timezone ...
    got:      DataFusion error: Failed to cast field 'column1' from Timestamp(ns)
              to Timestamp(ns, "America/Denver")
              caused by
              Arrow error: Cast error: Cannot cast timezone to different timezone

The wrapper chain is not what these cases pin. They pin which error class each
path raises: the column path raises a Cast error, and the literal path raises a
Parser error. So each expectation now matches only the `Arrow error: ...` tail.

This uses the single-line `query error <regex>` form. In sqllogictest 0.29.1 an
inline pattern goes through `Regex::is_match`, which is an unanchored search,
while the multiline `----` form requires an exact match. The tail appears
verbatim in both the old and the new message, so these expectations hold on
either side of apache#24920 and will not break on the next wrapper change.

Expectations and SQL are otherwise unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@adriangb

Copy link
Copy Markdown
Contributor Author

Self-review (QA pass) of our own PR, at head bafa661263. I started this review at a32121369b. The head moved during the review, so I ran each check again at bafa661263.

The main hazard of this file: a pinned bug without a comment reads as an intended contract. So I checked each expectation that records a bug or a divergence from PostgreSQL. For each one I asked two questions: does a comment name the problem, and does it link the specific issue?

Result. The file passes at this head, alone and merged with current main. But CI shows a red license check. I also found pins with no comment, pins that link the wrong issue, and three issues that the file says it pins but no expectation exercises. The merge-order section of the PR body is correct for the five PRs that I checked. It does not name #25182, which causes the red license check.

PostgreSQL answers below come from postgres:15. DuckDB answers come from DuckDB 1.5.2. DataFusion answers come from this file, or from datafusion-cli at the same base.

Findings (most important first)

1. CI: "Check License Header" fails at this head

Fix: put # on lines 8 and 10. #25164 has the same header and needs the same fix.

2. Section 8 links the wrong issue, and several of its pins have no comment

The Section 8 header says: "This is the area corrupted by unwrap_cast", and links #25095. But no query in Section 8 has the shape of 25095. That issue is about a naive column compared with an aware literal under a non-UTC session time zone.

Section 8 pins a different behaviour: a naive literal compared with an aware column is read in the time zone of the column, not in the session time zone. These expectations record that behaviour:

Lines Query (short form) DataFusion PostgreSQL 15 Comment in the file
534-537 cmp_denver WHERE ts = '2024-07-01 12:00:00', session unset / PG UTC the 18:00Z row the 12:00Z row yes, no issue link
542-545 cmp_denver WHERE ts > '2024-07-01 06:00:00' 1 row 2 rows yes, no issue link
554-557 cmp_utc WHERE ts = '2024-07-01 12:00:00', session America/Denver the 12:00Z row the 18:00Z row general note only, no link
578-582 c_denver: column1 = TIMESTAMP '2024-07-01 12:00:00', session unset / PG UTC false, true f, f none
613-625, 641-645 EXPLAIN of the same predicates constant folded in the column zone not applicable "so that any optimizer change shows up"; no divergence marker, no link

No issue in our list covers this exactly. The closest open issue is #13212 ("datafusion.execution.time_zone is not used for basic time zone inference"). Link 13212 on each of these queries, or file a narrow issue and link that. Remove the unwrap_cast sentence from the header.

3. Two KNOWN WRONG pins link 25095, but the cause is 25166

  • Lines 584-594: cmp_denver WHERE ts = '2024-07-01T06:00:00Z'::timestamptz returns the 12:00Z row. PostgreSQL 15 returns the 06:00Z row.
  • Lines 1214-1225: the CASE returns 2024-07-01T00:00:00-06:00, which is 06:00Z. PostgreSQL 15 returns 2024-07-01 00:00:00+00.

In both queries the session time zone is unset, so '...Z'::timestamptz gives a naive value (Section 0). The coercion then reads that wall clock in the Denver zone. The optimizer unwraps no cast on a column, so 25095 is not the cause. Link #25166 instead.

4. Lines 1191-1201 pin the wrong row, and the comment says the result is correct

SELECT ts FROM cmp_denver WHERE ts = '2024-07-01T06:00:00Z'::timestamptz

This is the second UNION ALL branch. It is the same query as in finding 3, so it returns the 12:00Z row. The expected output records 2024-07-01T12:00:00Z. The comment says "The instants are preserved". PostgreSQL 15 returns the 06:00Z row. Add a comment that names the wrong row and links #25166. Or set a session time zone for this query, so that the UNION test does not depend on this bug.

5. Three issues have no expectation that exercises them

The PR says that a fix "shows up here as an exact diff". For these three issues, a fix does not change this file:

Add one query for each.

6. Other pins with no comment or no link

7. Lines 733-742 call the date_bin origin drift a bug, but PostgreSQL and DuckDB drift the same way

For the two dst_origin rows under America/Denver:

Engine Bin for 2024-03-11T12:30:00Z
DataFusion (this file) 2024-03-11T01:00:00-06:00
PostgreSQL 15, date_bin 2024-03-11 01:00:00-06
DuckDB 1.5.2, time_bucket 2024-03-11 01:00:00-06

The pin is correct, and #25168 is the right link. But the comment must say that PostgreSQL and DuckDB give the same answer. Otherwise a fix can claim PostgreSQL parity that does not exist.

8. Low-priority notes

  • The header "Related issues" list does not include Document that date_bin with an explicit origin drifts an hour across a DST transition #25168.
  • Line 1181 says: "the naive side is treated as UTC". That is not true in general. I joined cmp_denver with naive_col, with the session time zone unset. The naive 2024-07-01 12:00:00 matches 2024-07-01T12:00:00-06:00, which is 18:00 UTC. So the naive side is read in the zone of the column. PostgreSQL 15 under UTC matches the 12:00 UTC row.
  • The PR body says that the 40 SET datafusion.execution.time_zone statements on main are "nearly all" in two files. On main they are in 9 files. set_variable.slt has 1 of them.
  • docs: document the TIMESTAMP WITH TIME ZONE type mapping and fix a stale comment #25171 adds docs that say DataFusion "discards the Z" in '...Z'::timestamptz. Lines 52-56 of this file say that the offset "is still honoured". Lines 90-95 confirm this: +05:30 becomes 06:30:00. The two texts must agree. The offset is applied, and only the time zone label is lost.

Merge-order collisions, checked against the file

I merged each PR into this branch at bafa661263 and ran this file, except where the diff shows no possible effect.

PR (head) Change Result of the merged run Expectations that change
#25165 (3e42f8b102) AT TIME ZONE on an aware value returns a naive value 2 failures lines 323 and 331 (5b): Timestamp(ns, "Europe/Brussels") 2024-01-15T13:00:00+01:00 becomes Timestamp(ns) 2024-01-15T13:00:00, and the Denver query changes the same way
#25163 (bdbfe1f8cd) date_part accepts timezone, timezone_hour and timezone_minute 2 failures lines 800 and 803: the two query error blocks now succeed
#25161 (fb72121aef) from_unixtime uses the session time zone pass none. The comment at lines 835-838 becomes stale
#25173 (aeb4122371) generate_series precision not run none: this file has no generate_series, and the PR changes only generate_series.rs and table_functions.slt
#25171 (095e6d3a31) a code comment and docs not run none: no behaviour change. See the Z note in finding 8
current main includes #24920 and #25182 pass none. At a32121369b, #24920 broke at least 10 Section 16 error expectations. bafa661263 fixes them

The updated #25165 adds a CASE limitation test in timestamps.slt. This file applies AT TIME ZONE to no CASE expression, so that test adds no collision.

What I checked and found correct

🤖 Generated with Claude Code

…ization suite

Upstream apache#25182 now checks license
headers in `.slt` files, and main uses `#` on header lines 8 and 10. This file
had empty lines there, so the license check fails.

A self-review of this PR found annotations that misdescribe what is pinned:

- Section 8 and two later comments linked 25095 (the unwrap_cast bug), but no
  query here has that shape. What they pin is 13212, the zone a tz-naive side
  is read in, and 25166, a tz-naive `'...Z'::timestamptz` literal. Links
  re-pointed; the header now says 25095 is not exercised.
- The UNION comment said "the instants are preserved", but its second row is
  the 12:00Z row where 06:00Z was requested -- the Section 8 problem again.
- The explicit-origin `date_bin` case was labelled KNOWN BUG. PostgreSQL 15.19
  (`date_bin`) and DuckDB 1.5.2 (`time_bucket`) return the same `01:00-06`, so
  it is inherent to instant-based binning, not a divergence. Reframed.
- Added missing links or divergence notes for `::timestamp` giving the UTC wall
  clock (12218), the `'+05:30'` sign convention (25170), `date_bin` versus
  `date_trunc` (25167), and the `timezone_hour` gap that PR 25163 fills.

Comments and header only. No query or expected output changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 81.91%. Comparing base (da89c7c) to head (6df7863).
⚠️ Report is 155 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #25175      +/-   ##
==========================================
+ Coverage   81.60%   81.91%   +0.31%     
==========================================
  Files        1123     1132       +9     
  Lines      408898   421314   +12416     
  Branches   408898   421314   +12416     
==========================================
+ Hits       333670   345135   +11465     
- Misses      55625    55771     +146     
- Partials    19603    20408     +805     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

pull Bot pushed a commit to TCeason/arrow-datafusion that referenced this pull request Sep 11, 2026
…pache#25161)

## Which issue does this PR close?

- Closes apache#12892

## Rationale for this change

### The bug, in SQL

Set a session time zone. Every other date and time function honours it.
`from_unixtime` does not.

```sql
SET datafusion.execution.time_zone = 'America/Denver';

-- now() and to_timestamp() honour the session time zone
SELECT arrow_typeof(now()), arrow_typeof(to_timestamp(1704110400));
-- Timestamp(ns, "America/Denver") | Timestamp(ns, "America/Denver")

-- from_unixtime() does not
SELECT arrow_typeof(from_unixtime(1704110400)), from_unixtime(1704110400);
-- Timestamp(s)                    | 2024-01-01T12:00:00
```

The last row is the defect. The user asks for a Denver session, but the
value comes back as a bare wall clock with no zone.
`2024-01-01T12:00:00` is the UTC clock, not the Denver clock.

After this PR the same query returns the session time zone:

```sql
SET datafusion.execution.time_zone = 'America/Denver';

SELECT arrow_typeof(from_unixtime(1704110400)), from_unixtime(1704110400);
-- Timestamp(s, "America/Denver") | 2024-01-01T05:00:00-07:00
```

The instant is the same. Only the declared type and the rendered offset
change.

### What changes for a user

- `from_unixtime(expr)` returns a value in
`datafusion.execution.time_zone`.
- `from_unixtime(expr, 'tz')` is unchanged. An explicit argument still
wins.
- With the session time zone unset, which is the default, nothing
changes at all.
- A value that no time zone can render is now an error, not a process
panic.

### Field research: PostgreSQL and DuckDB

Neither engine has a function named `from_unixtime`. The closest
equivalent in both is `to_timestamp(<epoch>)`, so that is the comparison
I ran. Both engines are timezone-aware there, and both render in the
session time zone.

**PostgreSQL 17.11**

```sql
SHOW TimeZone;                                  -- Etc/UTC
SELECT to_timestamp(1704110400),
       pg_typeof(to_timestamp(1704110400));
-- 2024-01-01 12:00:00+00 | timestamp with time zone

SET TimeZone = 'America/Denver';
SELECT to_timestamp(1704110400);                -- 2024-01-01 05:00:00-07
SELECT extract(epoch from to_timestamp(1704110400));   -- 1704110400.000000
```

PostgreSQL has no timezone-naive form of `to_timestamp`. A user must ask
for one:

```sql
SELECT to_timestamp(1704110400) AT TIME ZONE 'UTC';
-- 2024-01-01 12:00:00 | timestamp without time zone
```

**DuckDB v1.5.2**

```sql
SELECT current_setting('TimeZone'), to_timestamp(1704110400),
       typeof(to_timestamp(1704110400));
-- America/Chicago | 2024-01-01 06:00:00-06 | TIMESTAMP WITH TIME ZONE

SET TimeZone = 'America/Denver';
SELECT to_timestamp(1704110400);                -- 2024-01-01 05:00:00-07
SELECT epoch(to_timestamp(1704110400));         -- 1704110400.0
```

DuckDB defaults its session time zone to the operating system zone, so
it is never naive. The naive form is `make_timestamp(<microseconds>)`,
which returns `TIMESTAMP`.

**What the comparison supports, and what it does not**

- The type and the render rule of this PR agree with both engines. A
one-argument epoch conversion is timezone-aware and follows the session
time zone.
- The default differs, and this PR keeps DataFusion's default.
DataFusion leaves the session time zone unset, so `from_unixtime` stays
naive out of the box. PostgreSQL and DuckDB always have a session zone.
- The fixed-offset string form is not a meaningful comparison against
PostgreSQL. `SET TimeZone = '+08:00'` in PostgreSQL means UTC-08:00,
because PostgreSQL reads the string POSIX-style. DataFusion reads
`'+08:00'` as UTC+08:00. That divergence is separate and is tracked in
apache#25170.
- The out-of-range comparison is not meaningful either. The three
engines have three different timestamp ranges. PostgreSQL raises `ERROR:
timestamp out of range` at `to_timestamp(-8334601211039)`. DuckDB
renders the same value as `262145-12-31 (BC) 23:59:59.000448-04:56`.
DataFusion sits between the two, and this PR makes it raise an error
rather than panic.

## What changes are included in this PR?

### The fix

- `FromUnixtimeFunc` carries an `Option<Arc<str>> timezone` taken from
`config.execution.time_zone`, and implements
`ScalarUDFImpl::with_updated_config`. `NowFunc` and the `to_timestamp*`
functions already work this way. The single-argument form reports and
produces `Timestamp(Second, <session tz>)` from both
`return_field_from_args` and `invoke_with_args`.
- `from_unixtime` moves to `make_udf_function_with_config!`, and its
`expr_fn` gains the `@config` marker. The session `ConfigOptions` now
reach the function at registration time, and again on every `SET` or
`RESET`.
- `FromUnixtimeFunc::new()` is deprecated since `56.0.0` in favour of
`FromUnixtimeFunc::new_with_config()`. This mirrors the `to_timestamp*`
constructors. `Default` still yields the previous timezone-naive
behaviour.
- The `from_unixtime` documentation description was wrong before this
PR. It claimed an RFC3339 string with nanosecond precision and a `Z`
suffix, but the function returns an Arrow `Timestamp(Second, ...)`, and
the example directly below it shows a `-04:00` offset. The description
now states what the function returns, and
`docs/source/user-guide/sql/scalar_functions.md` is regenerated with
`dev/update_function_docs.sh`.

### The guard

Arrow renders a `Timestamp(Second, Some(tz))` through `chrono`'s
`DateTime::naive_local`, which panics when the shifted value leaves
`NaiveDateTime`'s range. The panic fires at render time, far from
`from_unixtime`. The two-argument form can always reach it. Once the
one-argument form applies a session zone, it can reach it too.

So `invoke_with_args` now checks the bound and returns a
`DataFusionError`:

```
Execution error: Cannot convert -8334601211039 to a timestamp in timezone "America/New_York"
for function from_unixtime: the local date and time is outside the supported range
```

The check has three parts:

1. A timezone-naive result is never shifted, so the check returns at
once. The full `NaiveDateTime` range stays usable, exactly as on `main`.
2. A fast path reads only the array's `min` and `max`. Inside
`[NaiveDateTime::MIN + 86400, NaiveDateTime::MAX - 86400]` no zone
offset can push a value out of range, so no per-value work happens.
3. Outside that window the check resolves the zone offset per value.

`86400` is a deliberate over-bound. The largest offset in the whole IANA
database is `Asia/Manila` at `-15:56:08` local mean time, and the
largest positive one is `America/Metlakatla` at `+15:13:42`. The largest
fixed offset Arrow's `Tz` parser accepts is `+23:59`, which is `86340`
seconds. `+24:00` is rejected by the parser.

## What is the testing strategy for this PR?

**New sqllogictest file**
`datafusion/sqllogictest/test_files/from_unixtime_timezone.slt`,
modelled on `to_timestamp_timezone.slt`. It asserts both `arrow_typeof`
and the value for:

- the session time zone unset,
- a fixed offset (`+08:00`),
- a named IANA zone (`America/Denver`),
- the explicit two-argument form under a set session time zone, which
must ignore it,
- array (not constant folded) input,
- `NULL` input,
- equality of one instant across two time zones,
- `RESET`, which must restore the timezone-naive behaviour.

The `to_unixtime` / `from_unixtime` round trip from the issue is in the
file. So are the out-of-range bounds, in both directions, for a named
zone and a fixed offset zone, for the one-argument and the two-argument
forms, and for array input.

**New unit tests** in
`datafusion/functions/src/datetime/from_unixtime.rs` cover
`return_field_from_args` and `invoke_with_args` with a session time zone
set, an explicit time zone that overrides it, and the out-of-range
bound.

Every bounds test renders its result. The unit tests go through
`arrow::util::display::ArrayFormatter`, and the sqllogictest cases use
`query P`. This is deliberate: the panic they guard against fires when
the value is formatted, not when it is produced, so a test that only
computes the value would pass either way.

**Verified locally**

- `cargo test -p datafusion-sqllogictest --test sqllogictests` — 506
files pass
- `cargo test -p datafusion-functions --lib datetime::from_unixtime` —
10 tests pass
- `cargo clippy -p datafusion-functions -p datafusion-sql --all-targets
-- -D warnings` — clean
- `cargo fmt --all --check` and `./ci/scripts/doc_prettier_check.sh` —
clean

**Verified against a `main` build.** I built `main` at the merge base
and this branch as two `datafusion-cli` binaries and diffed their
output. With the session time zone unset, eleven probes are
byte-identical, including the error paths. The two-argument form is
byte-identical across 140 pairs of value and zone. The full detail is in
a separate comment on this PR.

**Cost of the guard.** `SELECT count(from_unixtime(v)) FROM
generate_series(1, 100000000) t(v)`, three runs each, `ci` profile: 3.95
s / 3.65 s / 3.07 s with the zone unset, and 3.09 s / 3.78 s / 3.33 s
with `America/Denver`. The two extra kernel passes sit inside the noise.

## Are there any user-facing changes?

Yes, four.

**1. The behaviour change, which is the bug fix.** When
`datafusion.execution.time_zone` is set, `from_unixtime(expr)` returns
`Timestamp(Second, <session tz>)` instead of `Timestamp(Second, None)`.
The epoch value is unchanged. Only the declared type and the rendered
offset differ. With the default unset session time zone nothing changes.

**2. An API deprecation.** `FromUnixtimeFunc::new()` is deprecated since
`56.0.0` in favour of `FromUnixtimeFunc::new_with_config()`.
`Default::default()` stays available and behaves like the old `new()`.

**3. A public factory signature change.**
`datafusion::functions::datetime::from_unixtime()` becomes
`from_unixtime(&ConfigOptions)`, because it now uses
`make_udf_function_with_config!`. This breaks callers of that factory.
It is the established convention for config-aware functions in this
module: `now` and all five `to_timestamp*` functions already have this
signature on `main`, with no zero-argument shim. The `expr_fn` wrapper
`datafusion::functions::expr_fn::from_unixtime(expr)` keeps its
signature.


**4. A new error in place of a panic.** See below.

### Out of range values

Arrow renders a `Timestamp(Second, Some(tz))` value by a shift of the
UTC instant by the zone's offset. It does that with `chrono`'s
`DateTime::naive_local`, which **panics** when the shifted value leaves
`NaiveDateTime`'s range. The panic fires when the value is formatted,
not when it is cast, so it surfaces far from where it starts:

```sql
SELECT from_unixtime(-8334601211039, 'America/New_York');
-- thread 'main' panicked at chrono-0.4.45/src/datetime/mod.rs:579:14:
-- Local time out of range for `NaiveDateTime`
```

This PR replaces that panic with an error. The check applies to both
forms. Its deliberate limits:

- **Timezone-naive results are unaffected.** They are never shifted, so
the full `NaiveDateTime` range stays usable, exactly as on `main`.
- **A value that is out of range in UTC as well is left to the cast**,
which already reports it (`Cast error: Failed to convert 8210266876800
to datetime for ...`). No error message changes.
- **A query that consumed an out-of-range value without a render now
returns an error.** On `main` such a value is only fatal at render time,
so `SELECT to_unixtime(from_unixtime(-8334601211039,
'America/New_York'))` returns `-8334601211039` today and errors after
this PR. Every query that produced a rendered result keeps its
behaviour.

### Known interaction with issue 16594

This removes the panic reproducer in
apache#16594, which I verified
directly. `from_unixtime(-8334601211038 - 1, 'America/New_York')` errors
instead of a panic, and `from_unixtime(8210266876799 + 1,
'America/New_York')` keeps its existing cast error.

It does **not** close that issue. The issue asks that *all* `Int64`
values convert, and that is not reachable while `chrono::NaiveDateTime`
renders the value.

The underlying defect is an unguarded `naive_local()` in the Arrow
timestamp-with-timezone formatter. It stays reachable with no
`from_unixtime` at all:

```sql
SELECT arrow_cast(-8334601211039, 'Timestamp(Second, Some("America/New_York"))');
-- same panic

SET datafusion.execution.time_zone = 'America/New_York';
SELECT to_timestamp_seconds(-8334601211039);
-- same panic, on main and on this branch
```

A proper guard belongs in Arrow. The check here only stops
`from_unixtime` from handing the formatter a value the formatter cannot
render.

### Merge order with PR 25175

apache#25175 pins today's
`from_unixtime` behaviour in a characterization test file. **No
assertion in it breaks.** Its SECTION 10 cases run with the session time
zone unset, and this PR keeps `Timestamp(Second, None)` in that case. I
replayed both assertions on this branch and they still pass:

```
Timestamp(s) 2024-07-01T00:00:00
Timestamp(s, "America/Denver") 2024-06-30T18:00:00-06:00
```

The comment above them says `from_unixtime` "ignores
`datafusion.execution.time_zone`". That comment goes stale when this PR
lands, so whichever merges second must reword it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

sqllogictest SQL Logic Tests (.slt)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants