Skip to content

feat: support timezone, timezone_hour and timezone_minute in date_part - #25163

Open
adriangb wants to merge 3 commits into
apache:mainfrom
pydantic:feat-date-part-timezone-fields
Open

feat: support timezone, timezone_hour and timezone_minute in date_part#25163
adriangb wants to merge 3 commits into
apache:mainfrom
pydantic:feat-date-part-timezone-fields

Conversation

@adriangb

@adriangb adriangb commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

  • Part of Better timezone functionalities #10368. That issue lists several timezone gaps. This PR covers one bullet: "Extracting the time offset using date_part might be a nice to have". The other bullets stay open, so this PR does not close the issue.

Rationale for this change

What a user sees today

A timezone-aware timestamp carries a UTC offset, but nothing in DataFusion can read it back:

> SELECT date_part('timezone', TIMESTAMP '2024-07-01T12:00:00' AT TIME ZONE 'Europe/Brussels') AS utc_offset_seconds;
Execution error: Date part 'timezone' not supported

timezone_hour and timezone_minute fail the same way. There is no workaround. The offset is not a property of the type either, so a user cannot look it up once and reuse it: for a named zone it moves with daylight saving time.

What a user sees after this PR

> SELECT date_part('timezone', TIMESTAMP '2024-07-01T12:00:00' AT TIME ZONE 'Europe/Brussels') AS utc_offset_seconds;
+--------------------+
| utc_offset_seconds |
+--------------------+
| 7200               |
+--------------------+

7200 is +02:00, the summer offset of Europe/Brussels.

The new capability, in plain terms

Three new date_part fields report the UTC offset of a timezone-aware timestamp:

part meaning Europe/Brussels, July
timezone the whole UTC offset, in seconds 7200
timezone_hour whole hours of the offset 2
timezone_minute whole minutes of the offset, without the hours 0

Three things follow from the definition:

  • The value depends on the instant, not only on the type. Europe/Brussels gives 3600 in January and 7200 in July.
  • Offsets that are not whole hours work. Asia/Kolkata gives 19800 / 5 / 30.
  • A negative offset carries its sign on the minute as well as the hour. America/St_Johns in January gives -12600 / -3 / -30.

EXTRACT(TIMEZONE_HOUR FROM ...) is the alternative syntax for the same thing.

What changes are included in this PR?

Two commits.

1. refactor: move parse_tz from date_trunc into datetime::common. No behaviour change. parse_tz turns the optional timezone string of DataType::Timestamp into an arrow::array::timezone::Tz. It was private to date_trunc.rs. It now sits next to the other shared datetime helpers, so date_part reuses it instead of a second parser.

2. feat: support timezone, timezone_hour and timezone_minute in date_part. The three fields, with these design points:

  • Per row, not per type. Each value goes through as_datetime_with_timezone::<T>, and the code reads its offset back. One pass of PrimitiveArray::unary_opt computes the field, so there is no intermediate offset array. Input nulls survive.
  • Timezone-naive input is an error, not an implicit UTC. A Timestamp(_, None) carries no offset. The error text is Date part 'timezone' is not supported for timezone-naive timestamps, got Timestamp(ns). Dates, times, intervals and durations get a similar error. PostgreSQL rejects these inputs too. DuckDB instead returns 0, which this PR treats as the weaker choice: a plain 0 is indistinguishable from a real UTC value.
  • The return type is Int32. DataFusion already returns Int32 for every integral date_part field, and the two exceptions are epoch (Float64) and nanosecond (Int64). PostgreSQL returns double precision for all fields, but DataFusion diverges from that today for hour, minute and the rest. DuckDB returns BIGINT. So an integer type is both consistent here and precedented elsewhere.
  • EXTRACT needs no planner change. SQLExpr::Extract forwards the DateTimeField to date_part as its Display string. sqlparser already carries the Timezone, TimezoneHour and TimezoneMinute variants, so EXTRACT(TIMEZONE_HOUR FROM ...) arrives as "TIMEZONE_HOUR" and matches without regard to case.

No existing field changes behaviour. The new parts sit in the Err(_) arm that used to return Date part '{part}' not supported.

Field research

The sign of timezone_minute on a negative offset is easy to get backwards, so I measured it rather than assumed it.

PostgreSQL 17.11, in Docker, with SET TimeZone TO '<zone>' and a timestamptz literal:

zone offset kind instant timezone timezone_hour timezone_minute
Asia/Kolkata positive, 30 minutes 2024-07-01T12:00:00Z 19800 5 30
Asia/Kathmandu positive, 45 minutes 2024-07-01T12:00:00Z 20700 5 45
America/Denver negative, whole hour 2024-01-01T12:00:00Z -25200 -7 0
America/St_Johns negative, 30 minutes 2024-01-01T12:00:00Z -12600 -3 -30
Pacific/Marquesas negative, 30 minutes 2024-07-01T12:00:00Z -34200 -9 -30
Europe/Brussels DST zone, winter 2024-01-01T12:00:00Z 3600 1 0
Europe/Brussels DST zone, summer 2024-07-01T12:00:00Z 7200 2 0
Pacific/Chatham DST zone, 45 minutes 2024-01-01T12:00:00Z 49500 13 45
Pacific/Chatham DST zone, 45 minutes 2024-07-01T12:00:00Z 45900 12 45
UTC zero 2024-07-01T12:00:00Z 0 0 0

So the minute follows the sign of the offset. Pacific/Chatham is in the southern hemisphere, so its January value is the DST one. Also measured on the same server:

  • NULL in gives NULL out.
  • The part name ignores case.
  • tz is not an accepted spelling.
  • date, time and interval input are all rejected.

DuckDB 1.5.2 supports all three fields. This is worth stating plainly, because the opposite would also be a useful finding. DuckDB agrees with PostgreSQL on every zone and instant in the table above, sign included. Two differences from PostgreSQL:

  • DuckDB returns BIGINT, where PostgreSQL returns double precision.
  • DuckDB returns 0 for a timezone-naive TIMESTAMP, where PostgreSQL raises unit "timezone" not supported for type timestamp without time zone.

DuckDB rejects DATE and INTERVAL input, as this PR does.

This PR matches both engines on every value, and it follows PostgreSQL on the timezone-naive error.

What is the testing strategy for this PR?

Unit tests in datafusion/functions/src/datetime/date_part.rs, 9 new tests. timezone_parts_match_postgres is a table of 15 zone and instant combinations, asserted for all three fields. It covers every row of the PostgreSQL table above. The arrays are built by a relabel of UTC instants, so the cases line up exactly. The other 8 tests cover:

  • case insensitivity, including the spelling that EXTRACT produces;
  • an array across a DST transition, with a null row;
  • all four TimeUnit variants;
  • scalar input;
  • NULL input;
  • the timezone-naive rejection;
  • the non-timestamp rejection;
  • the Int32 return field.

sqllogictest cases appended to datafusion/sqllogictest/test_files/datetime/date_part.slt:

  • fixed offsets +05:30 and -03:30;
  • the exact query from the issue;
  • named zones in standard time and in daylight saving time;
  • the 45-minute Pacific/Chatham cases;
  • negative offsets that are not whole hours;
  • EXTRACT(...) syntax;
  • arrow_typeof of all three results;
  • NULL input;
  • a column across the exact Brussels spring-forward boundary, 01:59:59 against 03:00:00 local;
  • four error cases.

Verified locally, on each commit separately:

  • commit 1: cargo check --workspace --all-targets clean, cargo clippy -p datafusion-functions --all-targets -- -D warnings clean, 344 lib tests pass.
  • commit 2: cargo clippy --all-targets -- -D warnings clean across the workspace, 353 lib tests pass, the full sqllogictest suite passes.
  • cargo fmt --all, ./ci/scripts/doc_prettier_check.sh and typos are all clean.

Are there any user-facing changes?

Yes, and they are additive. Three new date_part and EXTRACT fields. No public API changes, and no existing field changes behaviour, so this PR needs no api change label.

docs/source/user-guide/sql/scalar_functions.md comes from the user_doc! block through dev/update_function_docs.sh. It gains the three parts, a note on the DST and sign behaviour, the timezone-only restriction, and a worked example.

One known limit: datafusion.execution.time_zone defaults to None, so now() is timezone-naive and date_part('timezone', now()) errors. SET datafusion.execution.time_zone makes it work. That is #25166 and it is out of scope here.

Merge order

#25175 is a characterization suite. Its SECTION 9 pins the current rejection:

query error DataFusion error: Execution error: Date part 'timezone_hour' not supported
SELECT date_part('timezone_hour', ts) FROM day_denver

Once this PR lands, timezone_hour and timezone_minute return -6 and 0 for every row of day_denver. So whichever of the two merges second must update the other, and its CI goes red until it does. That PR records the same collision in its own description.

🤖 Generated with Claude Code

@github-actions github-actions Bot added documentation Improvements or additions to documentation sqllogictest SQL Logic Tests (.slt) functions Changes to functions implementation labels Sep 10, 2026
adriangb and others added 2 commits September 10, 2026 13:29
`parse_tz` turns the optional timezone string carried by
`DataType::Timestamp` into an `arrow::array::timezone::Tz`. It was private
to `date_trunc.rs`; move it next to the other shared datetime helpers so
other functions in the crate can reuse it instead of rolling their own.

No behaviour change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ate_part`

`date_part('timezone', ...)` previously failed with
`Execution error: Date part 'timezone' not supported`. Add the three
PostgreSQL timezone fields:

- `timezone`        - the UTC offset in seconds
- `timezone_hour`   - whole hours of the offset
- `timezone_minute` - whole minutes of the offset, excluding the hours

The offset of a named timezone changes with daylight saving time, so these
are computed per row from the instant rather than from the type alone:
`Europe/Brussels` gives 3600 in January and 7200 in July. Offsets that are
not whole hours are handled (`Asia/Kolkata` -> 19800/5/30,
`Pacific/Chatham` -> 45900/12/45), and for a negative offset both the hour
and the minute carry the sign (`America/St_Johns` -> -12600/-3/-30), which
is PostgreSQL's convention.

These fields are only defined for timestamps that carry a timezone. A
timezone-naive timestamp has no offset, so like PostgreSQL we reject it
with a clear error rather than silently assuming UTC; the same applies to
dates, times, intervals and durations.

Like every other `date_part` field except `epoch`, the result is `Int32`
(PostgreSQL returns `double precision` for all fields; DataFusion already
diverges there for the integral fields).

`EXTRACT(TIMEZONE FROM ...)`, `EXTRACT(TIMEZONE_HOUR FROM ...)` and
`EXTRACT(TIMEZONE_MINUTE FROM ...)` work too - the SQL planner forwards the
`DateTimeField` to `date_part` as a string, and sqlparser already carries
those variants.

Expected values in the tests were cross-checked against PostgreSQL 17.

Part of apache#10368

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

codecov-commenter commented Sep 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.97571% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.92%. Comparing base (1ec9ede) to head (bdbfe1f).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
datafusion/functions/src/datetime/date_part.rs 98.33% 2 Missing and 2 partials ⚠️
datafusion/functions/src/datetime/common.rs 85.71% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #25163      +/-   ##
==========================================
+ Coverage   81.91%   81.92%   +0.01%     
==========================================
  Files        1132     1132              
  Lines      420570   421356     +786     
  Branches   420570   421356     +786     
==========================================
+ Hits       344490   345192     +702     
- Misses      55764    55771       +7     
- Partials    20316    20393      +77     

☔ 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.

@adriangb

Copy link
Copy Markdown
Contributor Author

Heads-up on a merge-order interaction: #25175 (timezone characterization tests) currently pins the current behaviour of these fields as errors —

query error DataFusion error: Execution error: Date part 'timezone_hour' not supported
SELECT date_part('timezone_hour', ts) FROM day_denver

— in datafusion/sqllogictest/test_files/datetime/timestamps_timezone.slt, Section 9, along with a comment saying there is no way to ask a timezone-aware value for its own offset.

Once this PR lands, those two query error blocks become value queries (-6 and 0 for every day_denver row) and that comment is no longer true. Whichever of the two merges second needs to update the other; nothing is broken by merging in either order. Noting it here so it is not a surprise.

@adriangb

Copy link
Copy Markdown
Contributor Author

Self-review: a QA pass on my own PR. I checked every claim in the description against a real PostgreSQL 17.11 and a real DuckDB 1.5.2, and I re-ran the build and the tests. I found no correctness bug. The sign convention, the return type and the EXTRACT path are all correct. Below are one user-visible gap, three nits, and then the full list of what I checked.


1. date_part('timezone', now()) fails out of the box

This is the first thing a user tries, and it does not work with the default configuration:

> SELECT arrow_typeof(now());
Timestamp(ns)

> SELECT date_part('timezone', now());
Execution error: Date part 'timezone' is not supported for timezone-naive timestamps, got Timestamp(ns)

datafusion.execution.time_zone defaults to None, so now() is timezone-naive. A session zone fixes it:

> SET datafusion.execution.time_zone = 'Europe/Brussels';
> SELECT arrow_typeof(now()), date_part('timezone', now());
Timestamp(ns, "Europe/Brussels")   7200

PostgreSQL answers date_part('timezone', now()) directly, because its now() is always timestamptz.

The root cause is #25166, not this PR, and the fix does not belong here. But the PR adds the exact function a user reaches for, so the dead end is new. Two cheap additions cover it:

  • a .slt case for now() with and without a session zone, which pins the gap and turns into a diff when 25166 lands;
  • one sentence in the user_doc! block that names datafusion.execution.time_zone.

2. The doc sentence over-states the sign rule

The doc block says: "For a negative offset both parts carry the sign". That is not exact. When the absolute offset is under one hour, the hour is 0 and loses the sign, and only the minute carries it. The code is still right — PostgreSQL does the same thing:

engine zone instant timezone timezone_hour timezone_minute
PostgreSQL 17.11 Africa/Monrovia 1900-01-01T12:00:00Z -2588 0 -43
this PR Africa/Monrovia 1900-01-01T12:00:00Z -2588 0 -43

Truncation towards zero matches on the seconds too, so this is an exact match and not a near miss. No modern zone has a negative offset under one hour, so only historical LMT data reaches it.

Two suggestions:

  • reword to "the minute carries the sign of the offset, and so does the hour when the offset is a whole hour or more";
  • add the Africa/Monrovia row to timezone_parts_match_postgres. It is the one case the current 15-row table does not reach, and both engines agree on it.

3. Move to_lowercase inside TimezonePart::parse

The call site is correct today, and I confirmed mixed case works after the #24906 rebase. But the invariant is fragile:

Err(_) => match TimezonePart::parse(&part_trim.to_lowercase()) {

part_normalization strips quotes only; it does not lower the case. DatePart::from_str and is_epoch each lower the case inside themselves. TimezonePart::parse is the one function in the file that depends on its caller. A second caller would silently break date_part('TIMEZONE_HOUR', ...), and no local test catches it. Move .to_lowercase() into parse so the type owns its own contract.

4. Two error shapes for one condition

The two messages read as two different rules:

  • Date part 'timezone' is not supported for timezone-naive timestamps, got Timestamp(ns)
  • Date part 'timezone' is only supported for timestamps with a timezone, got Date32

Both mean "this value has no UTC offset". One shape is easier to grep for and easier to document. Neither message tells the user what to do next. A hint helps: name AT TIME ZONE or datafusion.execution.time_zone.

5. Two small errors in the PR description

  • The count is 349 tests; the real number is 353. The lib suite goes from 344 to 353.
  • The "After this PR" block shows a column named utc_offset_seconds, but the query carries no such alias.

I fix both in the rewritten description.


What I checked, and what it measured

Sign convention, against PostgreSQL 17.11. SET TimeZone TO '<zone>', then date_part('<part>', timestamptz '<instant>'). All 10 rows of the table in the PR description reproduce exactly, and 6 more zone and instant pairs do too:

zone instant timezone timezone_hour timezone_minute
America/St_Johns 2024-01-01T12:00:00Z -12600 -3 -30
America/St_Johns 2024-07-01T12:00:00Z -9000 -2 -30
Pacific/Chatham 2024-01-01T12:00:00Z 49500 13 45
Pacific/Chatham 2024-07-01T12:00:00Z 45900 12 45
Asia/Kolkata both 19800 5 30
Asia/Kathmandu both 20700 5 45
Europe/Brussels Jan / Jul 3600 / 7200 1 / 2 0 / 0
America/Denver Jan / Jul -25200 / -21600 -7 / -6 0 / 0
Pacific/Marquesas both -34200 -9 -30
UTC both 0 0 0

So the negative minute is confirmed, not assumed.

DuckDB 1.5.2 supports all three fields, and it agrees with PostgreSQL on every zone above. Two useful differences:

  • DuckDB returns BIGINT, not double precision. So an integer return type has precedent, and Int32 here is not an outlier.
  • DuckDB returns 0 for a timezone-naive timestamp; PostgreSQL raises unit "timezone" not supported for type timestamp without time zone. This PR follows PostgreSQL. That is the right call, but the description presents 0 as self-evidently wrong when a major engine ships it. Worth a mention rather than a silent omission.

DuckDB rejects DATE and INTERVAL input, as this PR does.

Return type. return_field_from_args gives Float64 for epoch, Int64 for nanosecond and Int32 for everything else. So the claim in the description holds, and Int32 is the consistent choice. The divergence from PostgreSQL's double precision is pre-existing and applies to hour, minute and the rest already. It does not need a call-out in this PR.

EXTRACT needs no planner change. Confirmed end to end. sqlparser 0.62 renders DateTimeField::TimezoneHour as TIMEZONE_HOUR, the planner forwards that string, and the .slt cases for all three fields pass.

Case insensitivity after the #24906 rebase. date_part('TIMEZONE', ...), date_part('TIMEZONE_HOUR', ...) and date_part('Timezone_Minute', ...) all work, in the unit tests and in the .slt. See nit 3 for the fragility, not for a defect. Leading and trailing whitespace fails (date_part(' timezone ', ...)), exactly as it fails for every other part on main. Quoted spellings such as '''timezone''' work.

Nullability. unary_opt can turn an out-of-range value into a NULL even when the input field is not nullable. date_part('hour', ...) on the same value does the same thing, so Arrow's own kernel already behaves this way. Pre-existing and consistent, so no action.

preimage. DatePart::from_str("timezone") fails, so preimage returns PreimageResult::None and no filter rewrite fires. Constant folding still works: EXPLAIN shows Projection: Int32(7200).

Both commits build green on their own.

  • commit 1 (refactor: move parse_tz): cargo check --workspace --all-targets clean, cargo clippy -p datafusion-functions --all-targets -- -D warnings clean, 344 lib tests pass.
  • commit 2 (feat: ... date_part): cargo clippy --all-targets -- -D warnings clean on the whole workspace, 353 lib tests pass, datetime/date_part.slt green.

Generated docs are in sync. I re-ran print_functions_docs -- scalar and diffed the date_part section against the committed file. The content matches; the only differences are the prettier reflow that dev/update_function_docs.sh applies. ./ci/scripts/doc_prettier_check.sh and typos are both clean.

The doc example is real. SELECT date_part('timezone', TIMESTAMP '2024-07-01T12:00:00' AT TIME ZONE 'Europe/Brussels') returns 7200, and the input type is Timestamp(ns, "Europe/Brussels").

Merge order. #25175 pins Date part 'timezone_hour' not supported in its SECTION 9. Whichever PR merges second must update the other. That PR already records the collision in its own description.

🤖 Generated with Claude Code

The documentation said "for a negative offset both parts carry the sign". That
is inexact when the offset is smaller than one hour: the hour part is zero, and
zero cannot show a sign, so only the minute part is negative.

The implementation was already correct. `Africa/Monrovia` before 1972 has an
offset of -00:43:08, and this returns -2588 / 0 / -43, which is what
PostgreSQL 17 returns for the same instant. Only the wording was wrong.

Adds that case to the PostgreSQL comparison table, which previously had no
sub-hour offset.

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

documentation Improvements or additions to documentation functions Changes to functions implementation sqllogictest SQL Logic Tests (.slt)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants