Repository navigation
Description
In short: this keeps the approach from #22835, which maps -0.0 to 0.0 before Arrow compares anything, and completes it in three ways:
- apply the same mapping to sort order, not only to equality
- route it through one module instead of individual call sites
- add a differential test that finds the call sites that still skip it
Is your feature request related to a problem or challenge?
DataFusion treats -0.0 and 0.0 as equal (#22835), like PostgreSQL, DuckDB and Spark. Arrow's kernels don't: they compare floats by bits and sort them by IEEE total order, where -0.0 < 0.0. #22835 bridged the gap by rewriting -0.0 to 0.0 before some Arrow calls. Every comparison, sort or hash that skips the rewrite still treats them as different, and these keep turning up one at a time: #25186 (IN list), #25766 (window PARTITION BY), #26115 (sort-merge join), #24375 (ASOF). #26091 asks for the same treatment for NaN.
The bigger problem is that only equality was fixed, not ordering. Sorting still puts -0.0 before 0.0. For one column that is harmless. When another value follows the zero (multi-column, list or struct keys), sort order and equality disagree, and anything that sorts and then compares gets wrong results:
SELECT a, b FROM (VALUES (arrow_cast(-0.0, 'Float64'), 1), (0.0, 0)) t(a, b) ORDER BY a, b;
-- got (-0.0, 1), (0.0, 0)
-- expected (0.0, 0), (-0.0, 1): `a` ties, so `b` decides
SELECT a, rank() OVER (ORDER BY a) FROM (VALUES (arrow_cast(-0.0, 'Float64')), (0.0)) t(a);
-- got ranks 1, 2; expected 1, 1#26115 is the same problem in a sort-merge join, which drops matching rows. More cases that are still wrong are listed at the end.
Describe the solution you'd like
Map every float to one representative before comparing, sorting or hashing it: -0.0 → 0.0, and, if #26091 is accepted, every NaN → one NaN. On mapped values, Arrow's bit equality and total order already match SQL semantics, for nested types too. So no SQL-specific kernels are needed; the mapping just has to be applied everywhere.
-
One new module owns this, for example
datafusion_common::float_semantics(the name Comet uses for the same idea):- the mapping
- wrappers around the Arrow kernels (
eq, sort,RowConverter,make_comparator, ...) - SQL-order comparison for code that compares by hand (
compare_rows,evaluate_partition_ranges, IN list,array_*)
Operators map their keys once, when they evaluate them. For flat comparisons the module can compare in SQL order directly instead of copying, as Compare floats in Spark's order without normalizing copies of the operands datafusion-comet#6590 does.
-
One differential test. It runs special floats (±0, NaN variants, nested values) through operators that must agree: hash vs sort-merge joins, hash vs sorted aggregation, window partitions vs GROUP BY, ORDER BY vs a reference sort. This catches the next case whatever code path it comes from.
-
Optionally, a lint: list the raw Arrow kernels in
disallowed-methodsinclippy.toml, so new code goes through the module. A lint can't see hand-written comparisons, so the test is the main guard.
Values don't change: -0.0 still prints as -0.0. ScalarValue equality stays bitwise, because it is expression identity (1 / -0.0 ≠ 1 / 0.0). The visible change is that ORDER BY a treats -0.0 and 0.0 as ties, as PostgreSQL does.
Steps:
- Module, differential test and the sort path (closes SortMergeJoin misses rows for nested float keys containing -0.0 #26115)
- Move the feat: Support IEEE 754 negative zero semantics #22835 call sites onto the module (refactor: Centralize float normalization #26595)
- Window partitions, rank and RANGE frames (closes Window PARTITION BY puts 0.0 and -0.0 in separate partitions #25766)
- IN list, CASE,
nullif,array_*,min/maxon nested values,count(DISTINCT) - NaN, if feat: Treat all NaNs as the same value (
-NaN = NaN) #26091 is accepted - Optional lint
Describe alternatives you've considered
- Keep fixing call sites one by one. Fixes have arrived one at a time since feat: Support IEEE 754 negative zero semantics #22835 and the list keeps growing.
- Wrap key expressions in the plan, like Spark's
NormalizeFloatingNumbers. fix: treat signed zeros as equal in window PARTITION BY #26020 and feat: support floating-point ASOF equality keys #24375 each add aNormalizeFloatZeroExprthis way. Every float sort key would need the wrapper too, which changes how the optimizer matches orderings and partitioning. It also misses expression-level equality (IN, CASE,array_*). It would be good to agree on a direction before those two PRs merge.
Additional context
- datafusion-comet hit the same problem with Spark semantics and is fixing it the same way: [EPIC] Match Spark's -0.0 and NaN semantics systematically instead of per expression datafusion-comet#6385.
- Arrow uses total order on purpose (Add sql-compliant feature for enabling sql-compliant kernel behavior arrow-rs#2568, Make consistent behavior on zeros equality on floating point types arrow-rs#3509), so this belongs in DataFusion.
- Cost: nothing for non-float columns. For float keys, one pass per key column per batch, which is small next to a sort or join. Flat comparisons should compare in SQL order directly; Comet measured copying at about +50% there.
Other cases still wrong on main (checked Oct 29)
z is arrow_cast(-0.0, 'Float64').
| Query | Got | Expected |
|---|---|---|
ORDER BY a, b LIMIT 1 over (z, 1), (0.0, 0) |
(-0.0, 1) |
(0.0, 0) |
sum(b) OVER (ORDER BY a RANGE BETWEEN CURRENT ROW AND CURRENT ROW) over (z, 1), (0.0, 2) |
1, 2 | 3, 3 |
row_number() OVER (PARTITION BY a) over z, 0.0 |
1, 1 | 1, 2 |
first_value(b ORDER BY a, b) over (z, 1), (0.0, 0) |
1 | 0 |
DISTINCT ON (g) g, b ... ORDER BY g, a, b over (1, z, 1), (1, 0.0, 0) |
b = 1 | b = 0 |
ORDER BY k over k = [z, 1.0], [0.0, 0.0] |
[-0.0, 1.0] first |
[0.0, 0.0] first |
min(k), max(k) over the same lists |
[-0.0, 1.0], [0.0, 0.0] |
[0.0, 0.0], [-0.0, 1.0] |
z IN (0.0, 1.0), Float64 and Float32 |
false | true |
z IN (0.0, 1.0, ..., 25.0) (26 values) |
false | true |
array_has(make_array(z), 0.0), array_has_any, array_has_all |
false | true |
array_position(make_array(z, 1.0), 0.0) |
NULL | 1 |
array_replace(make_array(z, 1.0), 0.0, 9.0) |
[-0.0, 1.0] |
[9.0, 1.0] |
array_remove(make_array(z, 1.0), 0.0) |
[-0.0, 1.0] |
[1.0] |
nullif(z, 0.0) |
-0.0 | NULL |
CASE z WHEN 0.0 THEN 'eq' ELSE 'ne' END |
ne | eq |
count(DISTINCT a), count(DISTINCT b) over (z, 1), (0.0, 0) |
2, 2 | 1, 2 |
count(DISTINCT a) OVER () over z, 0.0 |
2 | 1 |
Activity
jayzhan211 commentedon Oct 8, 2026
I think someone proposed this idea before, but I can't remember where.
mkleen commentedon Oct 8, 2026
@jayzhan211 This looks great. I am happy to help. I filed #26091 a few days ago which is part of this epic.
mkleen commentedon Oct 22, 2026
take
refactor: centralize float canonicalization in `datafusion_common::fl…
geoffreyclaude commentedon Oct 23, 2026
This is great if it can be done without (too much) performance impact. I was struggling coming up with a clean implementation of #25186 .
jayzhan211 commentedon Oct 23, 2026
Agree +1
refactor: Centralize float normalization (apache#26595)
mkleen commentedon Oct 28, 2026
@jayzhan211 Do you have some input regarding the differential test ? Would be
sltsufficient here or do you picture something more clever ?jayzhan211 commentedon Oct 29, 2026
@mkleen slt is right for the cases we already know: each fix PR should pin its rows from the table in the description in slt. The differential test is for the cases nobody has found yet, so it can't rely on hand-written expected output.
The rule: changing a
-0.0to0.0must not change any result, apart from how-0.0prints. So the expected result comes from DataFusion itself, run on a copy where every-0.0is0.0.0.0,1.0,-1.0,inf, one NaN), so keys tie often. Add a uniqueid.0.0 IN (-0.0)is false while-0.0 = 0.0is true).-0.0as0.0, and compare. Repeat under several settings: hash vs sort-merge join, batch size 1, 1 vs 4 partitions, a memory limit that forces spilling, and both copies read from Parquet with small row groups.This also covers code with only one implementation (IN, CASE,
nullif,array_*), where comparing hash join with sort-merge join can't help. It would replace the "operators that must agree" test in the description; I'll update the description if you agree.I tried a quick SQL version on main: 25 queries over 4 rows, queries below. It flags every case in the table, plus some new ones, which I've added to the table:
count(DISTINCT a)counts-0.0and0.0separately when there is a second DISTINCT aggregate, or when it is a window function. On its own it passes, because it is planned as a GROUP BY.array_remove, andarray_has(k, 0.0)on a list column. With a columna,array_has(make_array(a), 0.0)passes, but only because it is rewritten toa = 0.0and never reaches thearray_haskernel.z IN (0.0, 1.0)now returns false too, not only Float32.prefer_hash_join = false, a join on aList(Float64)key returns 3 of 6 pairs, and two of the dropped pairs contain no-0.0(SortMergeJoin misses rows for nested float keys containing -0.0 #26115).GROUP BY and the flat equi-join pass.
For the real test I'd suggest Rust in
datafusion/core/tests/fuzz_cases/.aggregation_fuzzershows how to run a query under random settings, but its check sorts the rows, which would hide the ORDER BY bugs here. Some rules:count(DISTINCT), equi and range joins, set operations, IN, CASE,nullif,array_*,min/maxon lists.first_value, DISTINCT ON,row_number,lag, ROWS frames) ends withid.rankand RANGE frames don't, because their ties are what we test. Compare ORDER BY output in order and everything else as a multiset.-x,x * y), since it can create-0.0in the zero-only copy. No functions that can see the sign (1 / x,atan2,cot, casts to string).-NaN = NaN) #26091 is decided, add NaN payloads to the pool and to step 2.Does this match what you had in mind? If so, this test could be the next PR, before the sort-order change, and I'm happy to review it.
Queries (each runs on
rawand oncanonin place of{t})mkleen commentedon Oct 29, 2026
@jayzhan211 Thanks a lot for the input!