Skip to content

fix: use session timezone for timestamp subtraction - #25094

Open
kumarUjjawal wants to merge 4 commits into
apache:mainfrom
kumarUjjawal:fix/13212-timezone-inference
Open

fix: use session timezone for timestamp subtraction#25094
kumarUjjawal wants to merge 4 commits into
apache:mainfrom
kumarUjjawal:fix/13212-timezone-inference

Conversation

@kumarUjjawal

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

When a timezone-aware timestamp is subtracted from a timezone-naive timestamp, DataFusion does not use datafusion.execution.time_zone to interpret the naive value.

For example, with the session timezone set to +08:00, subtracting 2024-11-01 00:00:00 from 2024-11-01 00:00:00+00:00 returns zero instead of eight hours.

PostgreSQL and DuckDB handle this by implicitly casting the timezone-naive operand to the session timezone. DataFusion already produces the expected result when that cast is written explicitly, so the missing behavior belongs in type coercion.

What changes are included in this PR?

This PR:

  • Passes the configured session timezone to the type-coercion analyzer.
  • Casts the timezone-naive operand of mixed timestamptz - timestamp expressions using the session timezone.
  • Preserves the timezone of the timezone-aware operand.
  • Coerces both operands to the same timestamp precision.
  • Applies the same behavior to both operand orders and nested subqueries.
  • Passes the session timezone through ExprSimplifier::coerce, ensuring SessionContext::create_physical_expr behaves consistently with SQL planning.

The change is limited to timestamp subtraction. Comparison operators retain their existing behavior and should be handled separately.

The existing DST-boundary limitation described in #25084 remains. Because this PR inserts the previously missing cast automatically, mixed timestamp subtraction can now encounter that limitation for ambiguous or nonexistent local times.

When no session timezone is configured, existing behavior is unchanged.

What is the testing strategy for this PR?

The added tests cover:

The following checks pass:

cargo fmt --all -- --check
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test -p datafusion-optimizer
cargo test -p datafusion --test core_integration

The extended workspace test suite from the contributor guide also passes, including all 512 SQL logic test files.

Are there any user-facing changes?

There are no public API changes.

@github-actions github-actions Bot added optimizer Optimizer rules core Core DataFusion crate sqllogictest SQL Logic Tests (.slt) labels Sep 9, 2026
@adriangb
adriangb requested a balanced review from Copilot September 9, 2026 04:30

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The claimed nested-subquery coverage does not currently exercise subtraction inside a subquery.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Fixes #13212 by applying the session timezone when coercing mixed timezone-aware and timezone-naive timestamp subtraction.

Changes:

  • Propagates session timezone through type coercion, simplification, and subqueries.
  • Preserves timezone metadata while normalizing timestamp precision.
  • Adds analyzer, physical-expression, and SQL regression tests.
File summaries
File Description
datafusion/sqllogictest/test_files/datetime/timestamps.slt Tests mixed timestamp subtraction across timezones.
datafusion/optimizer/src/utils.rs Uses the new rewriter constructor.
datafusion/optimizer/src/simplify_expressions/expr_simplifier.rs Supplies session timezone during expression coercion.
datafusion/optimizer/src/scalar_subquery_to_join.rs Uses the new rewriter constructor.
datafusion/optimizer/src/analyzer/type_coercion.rs Implements timezone-aware subtraction coercion and propagation.
datafusion/core/tests/expr_api/mod.rs Tests direct physical-expression creation.
Review details
  • Files reviewed: 6/6 changed files
  • Comments generated: 1
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread datafusion/sqllogictest/test_files/datetime/timestamps.slt
@codecov-commenter

codecov-commenter commented Sep 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.89051% with 14 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.92%. Comparing base (7e5f40a) to head (8208a55).

Files with missing lines Patch % Lines
datafusion/optimizer/src/analyzer/type_coercion.rs 92.71% 7 Missing and 4 partials ⚠️
datafusion/expr-common/src/type_coercion/binary.rs 97.47% 2 Missing and 1 partial ⚠️
Additional details and impacted files
@@           Coverage Diff            @@
##             main   #25094    +/-   ##
========================================
  Coverage   81.91%   81.92%            
========================================
  Files        1132     1132            
  Lines      421117   421323   +206     
  Branches   421117   421323   +206     
========================================
+ Hits       344961   345151   +190     
- Misses      55767    55782    +15     
- Partials    20389    20390     +1     

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

let right_data_type = right.get_type(right_schema)?;
let (left_type, right_type) =
let (left_type, right_type) = if let Some(types) =
self.timestamp_subtraction_input_types(&left_data_type, &op, &right_data_type)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this go in BinaryTypeCoercer instead of the analyzer?

This special case lives in TypeCoercionRewriter, so the PR has to send the session timezone through four subquery call sites and through ExprSimplifier::coerce. One caller still does not get it: the coerce function in optimizer/src/utils.rs.

BinaryTypeCoercer in expr-common is the one source of coercion rules. The analyzer, the simplifier, the physical BinaryExpr::data_type, the statistics solver, and interval arithmetic all use it. A rule in BinaryTypeCoercer applies to all of them with no plumbing.

The cause of the bug is also in that file. In the arithmetic arm of signature_inner, the first branch asks arrow for a result type. Arrow accepts Timestamp(u, Some) - Timestamp(u, None) when the units are equal and reads the naive side as UTC. When the units differ, the pair falls through to temporal_coercion_strict_timezone, which casts the naive side to the aware side's timezone. This is what makes results depend on the units. A check before the arrow probe fixes that.

On main with SET TIME ZONE = '+08:00':

SELECT arrow_cast('2024-11-01T00:00:00Z', 'Timestamp(Nanosecond, Some("+08:00"))')  - '2024-11-01T00:00:00'::timestamp; -- 0 hours (wrong)
SELECT arrow_cast('2024-11-01T00:00:00Z', 'Timestamp(Millisecond, Some("+08:00"))') - '2024-11-01T00:00:00'::timestamp; -- 8 hours (right)


fn coerce(expr: Expr, schema: &DFSchema) -> Result<Expr> {
let mut expr_rewrite = TypeCoercionRewriter { schema };
let mut expr_rewrite = TypeCoercionRewriter::new(schema);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This caller does not get the session timezone, so the new rule does not apply here. See comment above.

if op != &Operator::Minus {
return None;
}
let session_time_zone = self.session_time_zone?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add a test to assert the current/expected behavior when datafusion.execution.time_zone is None? Something like:

statement ok
RESET datafusion.execution.time_zone

statement ok
SET datafusion.explain.logical_plan_only = true

# With no session timezone the naive operand is still read as UTC:
# 2024-11-01T00:00:00-04:00 is 04:00Z, and the naive value is taken as 00:00Z.
statement ok
CREATE TABLE no_session_tz AS SELECT
  arrow_cast('2024-11-01T00:00:00-04:00', 'Timestamp(Nanosecond, Some("America/New_York"))') AS ts_tz,
  '2024-11-01T00:00:00'::timestamp AS ts;

query ??
SELECT ts_tz - ts, ts - ts_tz FROM no_session_tz;
----
0 days 4 hours 0 mins 0.000000000 secs 0 days -4 hours 0 mins 0.000000000 secs

query TT
EXPLAIN SELECT ts_tz - ts FROM no_session_tz;
----
logical_plan
01)Projection: no_session_tz.ts_tz - no_session_tz.ts
02)--TableScan: no_session_tz projection=[ts_tz, ts]

statement ok
SET datafusion.explain.logical_plan_only = false

(I ran this against the PR branch: the values are 4 hours / -4 hours and no cast is inserted, so it records today's behaviour.)

),
_ => return None,
};
let DataType::Timestamp(unit, _) = comparison_coercion(left_type, right_type)?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we match on the two units directly, or make timeunit_coercion visible and call it instead of going through comparison_coercion?

----
0 days 8 hours 0 mins 0.000000000 secs

# The session timezone, not the aware operand's timezone, controls the cast.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rule seems to diverge for - and =. On this branch:

SET datafusion.execution.time_zone = '+08:00';
CREATE TABLE t AS SELECT arrow_cast('2024-11-01T04:00:00Z', 'Timestamp(Nanosecond, Some("America/New_York"))') AS ts_tz, '2024-11-01T00:00:00'::timestamp AS ts;
SELECT ts_tz = ts, ts_tz - ts FROM t;

returns true and 0 days 12 hours: = reads ts in America/New_York (so the two values are the same instant) while - reads it in the session timezone +08:00 (so they are 12 hours apart). Two values that compare equal yet differ by twelve hours.

@kumarUjjawal

Copy link
Copy Markdown
Contributor Author

Thanks for the review @adriangb

Pushed the changes 0390171

@adriangb adriangb left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pushing 0390171 — moving the rule into BinaryTypeCoercer and making = and - agree both look right to me, and the coverage in timestamps.slt is good.

A few small things inline.

Separately: the folds inlined out of get_coerce_type_for_list / get_coerce_type_for_case_when leave both of those pub functions with zero callers in the workspace, still encoding the pre-PR rule. They're exported from datafusion_expr::type_coercion::other, so an external caller now gets a coercion the planner no longer uses, and IN has two places to keep in sync. I've put a two-commit branch up against your fork that routes them through = instead, with tests and an upgrade-guide note: kumarUjjawal#3.

The PR description now contradicts the code in three places, and it's the description rather than the code that I think is wrong:

  • "Comparison operators retain their existing behavior and should be handled separately"0390171 coerces comparisons as well as subtraction. That's the right call, but the description says the opposite.
  • "Preserves the timezone of the timezone-aware operand"timestamp_types_with_session_timezone returns Timestamp(unit, Some(session_time_zone)) for both operands, and test_timestamp_session_timezone_coercion pins that (America/New_York vs naive becomes +08:00). It's semantically a no-op, since relabelling an aware timestamp preserves the instant, but the comment you added at timestamps.slt:2010 says the accurate thing and the description doesn't.
  • "There are no public API changes"BinaryTypeCoercer::with_session_time_zone is new public API.

One more that GitHub won't let me anchor inline, because datafusion/common/src/config.rs isn't in the diff: datafusion.execution.time_zone's doc is still

/// The default time zone
///
/// Some functions, e.g. `now` return timestamps in this time zone

This PR gives the setting a second meaning, so it'd be worth extending that doc and regenerating configs.md with ./dev/update_config_docs.sh. Careful: the description is asserted verbatim at information_schema.slt L448, L559 and L566 — miss those three and sqllogictest CI goes red. An entry in docs/source/library-user-guide/upgrading/56.0.0.md would be worth it too: the default is None, so nothing changes for anyone who leaves it unset, but results move for anyone who sets it.

@@ -244,7 +244,7 @@ fn evaluate_expr_with_null_column<'a>(
}

fn coerce(expr: Expr, schema: &DFSchema) -> Result<Expr> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This answers the question I raised last round about why no session timezone is threaded in here — worth writing down rather than leaving the omission to be rediscovered:

Suggested change
fn coerce(expr: Expr, schema: &DFSchema) -> Result<Expr> {
/// No session timezone is threaded in here on purpose. This runs over a plan the
/// `TypeCoercion` analyzer has already coerced, so every binary operand pair
/// already shares a type; the only type this helper introduces is the `Null` of
/// the dummy column in `evaluate_expr_with_null_column`, and `Null` against a
/// timestamp is short-circuited by `null_coercion` before the aware/naive rule.
fn coerce(expr: Expr, schema: &DFSchema) -> Result<Expr> {

let mut expr_rewrite = TypeCoercionRewriter {
schema: new_plan.schema(),
};
let mut expr_rewrite = TypeCoercionRewriter::new(new_plan.schema());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here — the reasoning is sound, it's just invisible:

Suggested change
let mut expr_rewrite = TypeCoercionRewriter::new(new_plan.schema());
// No session timezone: this builds a *searched* `CASE WHEN`, so the
// `CASE expr WHEN` comparison rule never applies, and the only type
// introduced is the untyped `Null` of the HAVING arm. The expressions
// themselves come from a plan `TypeCoercion` has already coerced.
let mut expr_rewrite = TypeCoercionRewriter::new(new_plan.schema());

)
.ok_or_else(|| {
internal_datafusion_err!(
"Failed to coerce types {expr_type} and {high_type} in BETWEEN expression"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This arm coerces low_coerced_type against high_type, but the message reports expr_type — the type it didn't coerce here. With the two-step fold, expr_type and low_type have already been reconciled, so naming all three is what actually tells you which pair failed:

Suggested change
"Failed to coerce types {expr_type} and {high_type} in BETWEEN expression"
"Failed to coerce types {expr_type}, {low_type} and {high_type} in BETWEEN expression"

}

Ok(())
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test_timestamp_session_timezone_coercion only exercises Some(tz); the None path — the one that has to stay exactly as it was in 55.0.0 — is covered only end to end in the slt. A unit test alongside this one pins the part that's easy to regress:

#[test]
fn test_timestamp_without_session_timezone_coercion() -> Result<()> {
    let aware_ms = DataType::Timestamp(Millisecond, Some("America/New_York".into()));
    let aware_ns = DataType::Timestamp(Nanosecond, Some("America/New_York".into()));
    let naive = DataType::Timestamp(Nanosecond, None);

    // Comparisons fall back to reading the naive side in the aware side's
    // timezone, in either operand order.
    for op in [Operator::Eq, Operator::Gt] {
        for (lhs, rhs) in [(&aware_ms, &naive), (&naive, &aware_ms)] {
            let (lhs, rhs) = BinaryTypeCoercer::new(lhs, &op, rhs)
                .with_session_time_zone(None)
                .get_input_types()?;
            assert_eq!((lhs, rhs), (aware_ns.clone(), aware_ns.clone()));
        }
    }

    // `Minus` only agrees with that when the units differ and force a coercion.
    // At a shared unit the operands are left alone and arrow subtracts their
    // raw values — which is exactly the behaviour this PR exists to correct.
    let (lhs, rhs) = BinaryTypeCoercer::new(&aware_ms, &Operator::Minus, &naive)
        .with_session_time_zone(None)
        .get_input_types()?;
    assert_eq!((lhs, rhs), (aware_ns.clone(), aware_ns.clone()));

    let (lhs, rhs) = BinaryTypeCoercer::new(&aware_ns, &Operator::Minus, &naive)
        .with_session_time_zone(None)
        .get_input_types()?;
    assert_eq!((lhs, rhs), (aware_ns.clone(), naive.clone()));

    // A session timezone doesn't disturb pairs that are both aware or both
    // naive: those already denote the same kind of value.
    let (lhs, rhs) = BinaryTypeCoercer::new(&aware_ms, &Operator::Minus, &aware_ms)
        .with_session_time_zone(Some("+08:00"))
        .get_input_types()?;
    assert_eq!((lhs, rhs), (aware_ms.clone(), aware_ms));

    let (lhs, rhs) = BinaryTypeCoercer::new(&naive, &Operator::Minus, &naive)
        .with_session_time_zone(Some("+08:00"))
        .get_input_types()?;
    assert_eq!((lhs, rhs), (naive.clone(), naive));

    Ok(())
}

That second Minus assertion is the interesting one: Timestamp(ns, Some(tz)) - Timestamp(ns, None) still coerces nothing without a session timezone, which is the 55.0.0 behaviour left intact.

@github-actions github-actions Bot added documentation Improvements or additions to documentation common Related to common crate labels Sep 11, 2026
kumarUjjawal and others added 4 commits September 11, 2026 11:15
Reuse session-aware comparison coercion across comparison forms and recurse through encoded and nested timestamp types. Document the public API changes and cover the behavior with unit and SQL logic tests.

Co-authored-by: Adrian Garcia Badaracco <1755071+adriangb@users.noreply.github.com>

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@kumarUjjawal
kumarUjjawal force-pushed the fix/13212-timezone-inference branch from d49d3d1 to 8208a55 Compare September 11, 2026 05:50
@github-actions

Copy link
Copy Markdown

Thank you for opening this pull request!

Reviewer note: cargo-semver-checks reported the current version number is not SemVer-compatible with the changes in this pull request (compared against the base branch).

Details
     Cloning apache/main
    Building datafusion v55.0.0 (current)
       Built [  58.326s] (current)
     Parsing datafusion v55.0.0 (current)
      Parsed [   0.038s] (current)
    Building datafusion v55.0.0 (baseline)
       Built [  57.671s] (baseline)
     Parsing datafusion v55.0.0 (baseline)
      Parsed [   0.038s] (baseline)
    Checking datafusion v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   0.935s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [ 118.915s] datafusion
    Building datafusion-common v55.0.0 (current)
       Built [  32.991s] (current)
     Parsing datafusion-common v55.0.0 (current)
      Parsed [   0.066s] (current)
    Building datafusion-common v55.0.0 (baseline)
       Built [  33.097s] (baseline)
     Parsing datafusion-common v55.0.0 (baseline)
      Parsed [   0.068s] (baseline)
    Checking datafusion-common v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   1.010s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [  68.538s] datafusion-common
    Building datafusion-expr v55.0.0 (current)
       Built [  28.510s] (current)
     Parsing datafusion-expr v55.0.0 (current)
      Parsed [   0.084s] (current)
    Building datafusion-expr v55.0.0 (baseline)
       Built [  28.084s] (baseline)
     Parsing datafusion-expr v55.0.0 (baseline)
      Parsed [   0.083s] (baseline)
    Checking datafusion-expr v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   1.860s] 223 checks: 222 pass, 1 fail, 0 warn, 31 skip

--- failure function_marked_deprecated: function #[deprecated] added ---

Description:
A function is now #[deprecated]. Downstream crates will get a compiler warning when using this function.
        ref: https://doc.rust-lang.org/reference/attributes/diagnostics.html#the-deprecated-attribute
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.50.0/src/lints/function_marked_deprecated.ron

Failed in:
  function datafusion_expr::type_coercion::other::get_coerce_type_for_list in /home/runner/work/datafusion/datafusion/datafusion/expr/src/type_coercion/other.rs:67
  function datafusion_expr::type_coercion::other::get_coerce_type_for_case_when in /home/runner/work/datafusion/datafusion/datafusion/expr/src/type_coercion/other.rs:91

     Summary semver requires new minor version: 0 major and 1 minor checks failed
    Finished [  59.969s] datafusion-expr
    Building datafusion-expr-common v55.0.0 (current)
       Built [  19.239s] (current)
     Parsing datafusion-expr-common v55.0.0 (current)
      Parsed [   0.020s] (current)
    Building datafusion-expr-common v55.0.0 (baseline)
       Built [  19.267s] (baseline)
     Parsing datafusion-expr-common v55.0.0 (baseline)
      Parsed [   0.020s] (baseline)
    Checking datafusion-expr-common v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   0.310s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [  39.740s] datafusion-expr-common
    Building datafusion-optimizer v55.0.0 (current)
       Built [  26.977s] (current)
     Parsing datafusion-optimizer v55.0.0 (current)
      Parsed [   0.033s] (current)
    Building datafusion-optimizer v55.0.0 (baseline)
       Built [  27.227s] (baseline)
     Parsing datafusion-optimizer v55.0.0 (baseline)
      Parsed [   0.034s] (baseline)
    Checking datafusion-optimizer v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   0.235s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [  55.473s] datafusion-optimizer
    Building datafusion-sqllogictest v55.0.0 (current)
       Built [  99.487s] (current)
     Parsing datafusion-sqllogictest v55.0.0 (current)
      Parsed [   0.023s] (current)
    Building datafusion-sqllogictest v55.0.0 (baseline)
       Built [  98.806s] (baseline)
     Parsing datafusion-sqllogictest v55.0.0 (baseline)
      Parsed [   0.025s] (baseline)
    Checking datafusion-sqllogictest v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   0.128s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [ 201.471s] datafusion-sqllogictest

@github-actions github-actions Bot added the auto detected api change Auto detected API change label Sep 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auto detected api change Auto detected API change common Related to common crate core Core DataFusion crate documentation Improvements or additions to documentation logical-expr Logical plan and expressions optimizer Optimizer rules sqllogictest SQL Logic Tests (.slt)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

datafusion.execution.time_zone is not used for basic time zone inference

4 participants