Repository navigation
Unsafe comparison cast rewriting in ExprSimplifier silently produces wrong query results #22142
Description
Activity
Ok, I have found this whole issue quite confusing as it is not clear to me what the scope of the issue is from this ticket as it describes a code problem, rather than the wrong results that can result. Specifically it is not clear to me from this issue, nor the various PRs that have been proposed, if this is something specific to timestamps or if it is a more general problem
-- Two DISTINCT nanosecond timestamps that both truncate to the SAME millisecond. CREATE TABLE t AS SELECT arrow_cast(1704067200001000000, 'Timestamp(Nanosecond, None)') AS ts_ns UNION ALL SELECT arrow_cast(1704067200001234567, 'Timestamp(Nanosecond, None)'); -- WRONG: should return 2 rows, returns 1. SELECT * FROM t WHERE arrow_cast(ts_ns, 'Timestamp(Millisecond, None)') = arrow_cast(1704067200001, 'Timestamp(Millisecond, None)'); Elapsed 0.002 seconds. +-------------------------+ | ts_ns | +-------------------------+ | 2024-01-01T00:00:00.001 | +-------------------------+ 1 row(s) fetched. Elapsed 0.000 seconds.
You can see this is incorrect as you both rows have the truncated values
SELECT arrow_cast(ts_ns, 'Timestamp(Millisecond, None)') from t; +----------------------------------------------------------+ | arrow_cast(t.ts_ns,Utf8("Timestamp(Millisecond, None)")) | +----------------------------------------------------------+ | 2024-01-01T00:00:00.001 | | 2024-01-01T00:00:00.001 | +----------------------------------------------------------+ 2 row(s) fetched. Elapsed 0.000 seconds.
The same thing happens with decimals:
CREATE TABLE t(d DECIMAL(20,4)) AS VALUES (1.2345), (1.2399), (1.2500); -- 1.2345 -> 1.23 at scale 2, so this MUST return 1 row: SELECT * FROM t WHERE arrow_cast(d, 'Decimal128(20, 2)') = arrow_cast(1.23, 'Decimal128(20, 2)'); -- predicate rewritten to `d = 1.2300` (exact) -> returns 0 rows. WRONG. +---+ | d | +---+ +---+ 0 row(s) fetched. Elapsed 0.001 seconds.
The original optimization was based on Spark's
UnwrapCastInBinaryComparisonas I recall: https://github.com/apache/spark/blob/a6e3fdd504cbff4dff739801e0b0b0f2b6402502/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/UnwrapCastInBinaryComparison.scala#L30-L101It seems that that code has a 2 layer check,
Layer 1: first on data types
https://github.com/apache/spark/blob/a6e3fdd504cbff4dff739801e0b0b0f2b6402502/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/UnwrapCastInBinaryComparison.scala#L462-L485Layer 2: checks if the literal can be casted losslessly
https://github.com/apache/spark/blob/a6e3fdd504cbff4dff739801e0b0b0f2b6402502/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/UnwrapCastInBinaryComparison.scala#L112
https://github.com/apache/spark/blob/a6e3fdd504cbff4dff739801e0b0b0f2b6402502/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/UnwrapCastInBinaryComparison.scala#L282-L340This seems somewhat equivalent to
try_cast_literal_to_typehttps://github.com/apache/datafusion/blob/eedae1154bf2745ea6d025f3e55901db1d8b7fb7/datafusion/expr-common/src/casts.rs#L59-L58So it seems to me that DataFusion is missing the Layer 1 check, and it seems like your PR in #22837 is adding this check.
So in my opinion, we should proceed with #22837 and I will do so
Ok, I have found this whole issue quite confusing as it is not clear to me what the scope of the issue is from this ticket as it describes a code problem, rather than the wrong results that can result. Specifically it is not clear to me from this issue, nor the various PRs that have been proposed, if this is something specific to timestamps or if it is a more general problem
-- Two DISTINCT nanosecond timestamps that both truncate to the SAME millisecond.
CREATE TABLE t AS
SELECT arrow_cast(1704067200001000000, 'Timestamp(Nanosecond, None)') AS ts_ns
UNION ALL
SELECT arrow_cast(1704067200001234567, 'Timestamp(Nanosecond, None)');-- WRONG: should return 2 rows, returns 1.
SELECT * FROM t
WHERE arrow_cast(ts_ns, 'Timestamp(Millisecond, None)') = arrow_cast(1704067200001, 'Timestamp(Millisecond, None)');Elapsed 0.002 seconds.
+-------------------------+
| ts_ns |
+-------------------------+
| 2024-01-01T00:00:00.001 |
+-------------------------+
1 row(s) fetched.
Elapsed 0.000 seconds.You can see this is incorrect as you both rows have the truncated values
SELECT arrow_cast(ts_ns, 'Timestamp(Millisecond, None)') from t;
+----------------------------------------------------------+
| arrow_cast(t.ts_ns,Utf8("Timestamp(Millisecond, None)")) |
+----------------------------------------------------------+
| 2024-01-01T00:00:00.001 |
| 2024-01-01T00:00:00.001 |
+----------------------------------------------------------+
2 row(s) fetched.
Elapsed 0.000 seconds.The same thing happens with decimals:
CREATE TABLE t(d DECIMAL(20,4)) AS VALUES (1.2345), (1.2399), (1.2500);
-- 1.2345 -> 1.23 at scale 2, so this MUST return 1 row:
SELECT * FROM t
WHERE arrow_cast(d, 'Decimal128(20, 2)') = arrow_cast(1.23, 'Decimal128(20, 2)');
-- predicate rewritten tod = 1.2300(exact) -> returns 0 rows. WRONG.
+---+
| d |
+---+
+---+
0 row(s) fetched.
Elapsed 0.001 seconds.sorry I didn't make it more clear, it's not just timestamp but all kind of types cast could have similar issues, I just want to keep pr small and fix them one by one without one massive pr blocking all wrong cast, the block list one I will get to merge first, then maybe could look into the preimage cast one which allow a wider range of correct cast unwrap
Reacted by Andrew Lamb- added a commit that references this issue
on Jun 22, 2026
Describe the bug
The
unwrap_castoptimization inExprSimplifier(both logical and physical) rewrites patterns likeCAST(column AS target_type) <op> literal_targetintocolumn <op> CAST(literal_target AS source_type)without verifying that the rewrite preserves query semantics. This default-allow behavior silently produces incorrect results for many common cast patterns.Specific broken scenarios
Timestamp precision downcast (many→one rewriting):
Source domain not preserved (overflow/null loss):
Type semantics changed (string round-trip, float truncation):
Timezone silently dropped:
Additional affected patterns
Impact
The optimizer is silently producing wrong query results. There is no error or warning — the plan looks correct and the query returns results that are simply semantically wrong. This has existed since the feature was introduced.
Proposed fix
Replace the default-allow logic with a closed-by-default allowlist. Only cast patterns proven safe (with literal round-trip verification) should be eligible. Implementation in #21908.
To Reproduce
See
datafusion/sqllogictest/test_files/unwrap_cast.sltor the unit tests indatafusion/expr-common/src/casts.rs,datafusion/optimizer/src/simplify_expressions/unwrap_cast.rs,datafusion/physical-expr/src/simplifier/unwrap_cast.rs.