[SPARK-59505][PYTHON][CONNECT] Round HALF_UP when rescaling decimals in LocalDataToArrowConversion by NestDream · Pull Request #58797 · apache/spark · GitHub
Skip to content

[SPARK-59505][PYTHON][CONNECT] Round HALF_UP when rescaling decimals in LocalDataToArrowConversion - #58797

Open
NestDream wants to merge 1 commit into
apache:masterfrom
NestDream:fix-arrow-decimal-rounding
Open

NestDream wants to merge 1 commit into
apache:masterfrom
NestDream:fix-arrow-decimal-rounding

Conversation

@NestDream

@NestDream NestDream commented Sep 14, 2026

Copy link
Copy Markdown

What changes were proposed in this pull request?

LocalDataToArrowConversion rescales a Python Decimal to the declared scale with a decimal.Context whose rounding mode is ROUND_HALF_EVEN, Python's default. This PR changes it to ROUND_HALF_UP, which is what the JVM uses whenever it rescales a decimal (Decimal.set, Decimal.changePrecision, CAST in ANSI and non-ANSI mode), and therefore what the pickled Python UDF and UDTF paths and Classic createDataFrame produce, since the JVM does the rescale for them. Tests are added for the converter, the Arrow-optimized Python UDF, the Arrow Python UDTF, a Python data source and createDataFrame, with values that sit exactly on a rounding tie, next to the test_decimal_round tests that SPARK-53938 added.

The pandas-based paths (spark.sql.legacy.execution.pythonUDF.pandas.conversion.enabled, scalar pandas_udf) do not use this converter; they hand the values to pyarrow casts, which raise on such values with the default spark.sql.execution.pandas.convertToArrowArraySafely and truncate with it off. They are unchanged here.

Why are the changes needed?

With the default Arrow-optimized Python UDF, udf(lambda v: Decimal(v), DecimalType(20, 2)) returns 1.00 for '1.005', 1.02 for '1.025' and 0.12 for '0.125', while CAST(v AS DECIMAL(20, 2)) and the same UDF with useArrow=False return 1.01, 1.03 and 0.13. Spark Connect's createDataFrame goes through the same converter, so createDataFrame([(Decimal("1.005"),)], "d decimal(20, 2)") gives 1.00 in a Connect session and 1.01 in Classic. Arrow UDTFs and Python data sources use the converter as well. The rounding was introduced by SPARK-53938 (#52637), whose goal was to match Classic; the explicit Context arrived there to fix an InvalidOperation on 38-digit values (#52637 (comment)) and the rounding mode itself was not discussed. It shipped in 4.1.0 and is in 4.2.0 and 4.3.0-rc1.

I reproduced this against a distribution built from unmodified master and against released 4.2.0, in Classic and Connect sessions, with pandas 3.0.5 and pyarrow 25.0.1. With this change every path returns 1.01, 1.03 and 0.13, the same as CAST.

Does this PR introduce any user-facing change?

Yes. A decimal returned by an Arrow-optimized Python UDF, an Arrow UDTF or a Python data source, or passed to createDataFrame through Spark Connect, that has more fractional digits than the declared scale and sits exactly on a tie now rounds HALF_UP like CAST does, instead of HALF_EVEN. Values that are not on a tie are unchanged. Since this is a wrong-result fix in released 4.1.x and 4.2.x, I would propose backporting it to the maintenance branches.

How was this patch tested?

New tests: test_decimal_rescale_rounds_half_up in test_conversion.py; test_decimal_round_half_up in test_arrow_python_udf.py (three Classic suites and their three Connect parity suites), test_udtf.py (Classic and Connect parity), test_python_datasource.py (Classic and Connect parity) and test_creation.py (Classic and Connect parity). On master every one of them fails with the HALF_EVEN values except the Classic createDataFrame test, which passes on master because Classic rescales on the JVM and stays green. The modules test_conversion, test_arrow_python_udf, test_creation, test_parity_creation, test_udf, test_udtf and test_python_datasource pass with the change (the two test_udf_with_input_file_name tests in test_udf and test_arrow_python_udf read a path relative to the source tree and were run from there). The reproduction script from the ticket was run against released 4.2.0 and against a master build with and without the change, in Classic and Connect sessions.

Was this patch authored or co-authored using generative AI tooling?

Authored by Li Guo, assisted by Claude Code (Fable 5.1).

@NestDream
NestDream force-pushed the fix-arrow-decimal-rounding branch 3 times, most recently from d142c31 to d6d8464 Compare September 14, 2026 23:09
@NestDream

Copy link
Copy Markdown
Author

@HyukjinKwon HyukjinKwon left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

0 blocking, 0 non-blocking, 0 nits.
The fix is correct, minimal, and well-scoped. value.quantize(exp, context=ctx) now rounds HALF_UP, matching JVM BigDecimal semantics (away from zero on ties, including negatives, which the tests cover with -1.005); only the rounding mode changed, so the SPARK-53938 bounded-precision context that avoided InvalidOperation on 38-digit values is preserved. The test coverage is thorough and cross-checks directly against CAST. Looks good to merge from a code standpoint; the only open point is the migration-note version/section, which the author already raised.

Verification

Static review; no build/tests run by me. Traced the converter (conversion.py:_create_converter decimal branch and LocalDataToArrowConversion.convert), confirmed quantize uses the new context, and reviewed all six changed test/doc files. The three deterministic scanners (text_quality, contract_claim_verifier, local_efficiency) reported NO FINDINGS; text_quality independently confirmed Decimal.set/Decimal.changePrecision use ROUND_HALF_UP in sql/api/.../types/Decimal.scala, corroborating the added comment.

PR metadata suggestions

  • Migration-guide placement: the note is under 'Upgrading from PySpark 4.3 to 5.0' and says 'In Spark 5.0'. Since this is a wrong-result fix in released 4.1.x/4.2.x that you propose backporting, if it lands in branch-4.x (4.3) the note should move to the 'Upgrading from PySpark 4.2 to 4.3' section and read 'In Spark 4.3'. (You already called this out; flagging so the committer sets it to the final fix version.)

Upgrading PySpark
==================

Upgrading from PySpark 4.3 to 5.0

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.

I think we will have 4.4. and I personally think we don't need such a notification here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

That makes sense, I've removed it

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 the fix!

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

yw!

…in LocalDataToArrowConversion

LocalDataToArrowConversion rescales a Decimal to the declared scale with a
decimal.Context whose rounding is ROUND_HALF_EVEN, Python's default. The JVM rounds
HALF_UP whenever it rescales a decimal (Decimal.set, Decimal.changePrecision, CAST), and
the pickled Python UDF and UDTF paths and Classic createDataFrame inherit that because
the JVM does the rescale for them. So an Arrow-optimized Python UDF, an Arrow UDTF, a
Python data source or Spark Connect's createDataFrame turned 1.005 into 1.00 where SQL
and Classic give 1.01, and Connect and Classic disagreed on the same createDataFrame
input.

The context now uses ROUND_HALF_UP. Tests cover the converter, the Arrow UDF, the Arrow
UDTF, a Python data source and createDataFrame (Classic and Connect) with values that
sit exactly on a rounding tie.
@NestDream
NestDream force-pushed the fix-arrow-decimal-rounding branch from d6d8464 to 579bbd0 Compare September 15, 2026 05:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants