Skip to content

fix(polars): coerce to Decimal without a Float64 round trip - #2574

Merged
cosmicbboy merged 1 commit into
unionai-oss:mainfrom
DG47:bugfix/polars-decimal-coerce-precision
Oct 16, 2026
Merged

cosmicbboy merged 1 commit into
unionai-oss:mainfrom
DG47:bugfix/polars-decimal-coerce-precision

Conversation

@DG47

@DG47 DG47 commented Oct 15, 2026

Copy link
Copy Markdown
Contributor

Fixes #2109.

Summary

Decimal.coerce in the polars engine casts the column to Float64 before casting it to the target decimal, so any value that does not survive a float round trip loses digits:

import polars as pl
import pandera.polars as pa

schema = pa.DataFrameSchema(
    {
        "a": pa.Column(pl.Decimal(11, 2)),
        "b": pa.Column(pl.Decimal(38, 2)),
        "c": pa.Column(pl.Decimal(38, 0)),
    },
    coerce=True,
)
df = pl.DataFrame(
    {"a": ["16809.76"], "b": ["12345678901234567.89"], "c": [123456789012345678]},
    schema={"a": pl.String, "b": pl.String, "c": pl.Int64},
)
schema.validate(df)
# a: 16809.75 on polars 1.20.0 and 1.33.1 (16809.76 on 1.42.1 and 2.0.0)
# b: 12345678901234567.68 on every polars version
# c: 123456789012345680 on every polars version

Polars can cast integers, decimals and strings to Decimal directly, so the float step is not needed for them.

Changes

  • pandera/engines/polars_engine.py: Decimal.coerce now picks the cast per column dtype:
    • integers and decimals are cast directly to the target decimal;
    • strings are parsed directly, and only values the direct parse rejects fall back to the previous Float64 path. Scientific notation ("1e3") is the case that needs this: on polars 1.20.0 and 1.33.1 the direct string-to-decimal cast rejects it, while the float path accepts it;
    • floats, booleans and anything else keep the previous Float64 path (polars does not cast Boolean to Decimal directly).
  • New tests in tests/polars/test_polars_dtypes.py:
    • test_coerce_decimal_keeps_precision: strings, large integers, a wider decimal, scientific notation, floats and booleans.
    • test_coerce_decimal_keeps_precision_in_schema: the schema-level example from the issue. It is marked xfail under the narwhals backend, which does not apply coerce=True, the same way as the existing coercion tests in tests/polars/test_polars_container.py.
    • test_try_coerce_decimal_invalid_string: a non-numeric string still raises ParserError with that value as the failure case.

Tests

  • New tests on unmodified main: 5 failed on polars 1.20.0, 4 failed on polars 2.0.0 (where "16809.76" happens to round correctly through the float).
  • pytest tests/polars with this change:
    • polars 1.20.0: 478 passed, 5 skipped
    • polars 1.33.1: 478 passed, 5 skipped
    • polars 1.42.1: 482 passed, 4 skipped
    • polars 1.42.1 with PANDERA_USE_NARWHALS_BACKEND=True: 410 passed, 1 skipped, 75 xfailed
    • polars 2.0.0: 480 passed, 4 skipped, 2 failed. The failures (test_dataframe_model_with_annotated_nested_types and test_polars_pydantic.py::test_conversion_types) also fail on unmodified main with polars 2.0.0 and are not related to this change.
  • pytest tests/base: 19 passed.
  • prek run --files pandera/engines/polars_engine.py tests/polars/test_polars_dtypes.py: all hooks pass (ruff, ruff format, mypy, pyupgrade, flynt, codespell).

Backward compatibility

No existing tests were changed. The user-visible change is that coercing integer, decimal and string columns to pl.Decimal now keeps the exact input value instead of the nearest float.

  • String columns: the previous Float64 path is still evaluated as the fallback, so every string that coerced before still coerces, and strings that failed before still fail with the same polars error.
  • Integer and decimal columns: values that coerced before still coerce. A few values that failed only because the float rounding pushed them past the target precision now coerce, for example 999999999999999999 into pl.Decimal(18, 0).
  • Float and boolean columns go through the same casts as before.

Decimal.coerce cast every column to Float64 before casting to the target
decimal, so strings, integers and wider decimals lost digits: "16809.76"
became 16809.75 on polars 1.33.1 and earlier, and 123456789012345678 became
123456789012345680 on every polars version.

Integers and decimals are now cast to the target decimal directly.
Strings are parsed directly and fall back to the previous Float64 path
only for values the direct parse rejects, so inputs that coerced before
(for example scientific notation on older polars) still coerce. Floats
and booleans keep the Float64 path.

Fixes unionai-oss#2109

Signed-off-by: Dhruv Gupta <[email protected]>
@codecov

codecov Bot commented Oct 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 82.71%. Comparing base (eb3bf25) to head (ddd5a6a).

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #2574   +/-   ##
=======================================
  Coverage   82.70%   82.71%           
=======================================
  Files         240      240           
  Lines       21215    21226   +11     
=======================================
+ Hits        17546    17557   +11     
  Misses       3669     3669           
Flag Coverage Δ
unit-tests-narwhals-backend-polars-py3.10 30.96% <100.00%> (+0.03%) ⬆️
unit-tests-narwhals-backend-polars-py3.11 30.95% <100.00%> (+0.03%) ⬆️
unit-tests-narwhals-backend-polars-py3.12 30.95% <100.00%> (+0.03%) ⬆️
unit-tests-narwhals-backend-polars-py3.13 30.95% <100.00%> (+0.03%) ⬆️
unit-tests-narwhals-backend-polars-py3.14 30.84% <100.00%> (+0.03%) ⬆️
unit-tests-polars-Linux-py3.10-pandas2.3.3-polars1.20.0 26.12% <100.00%> (+0.04%) ⬆️
unit-tests-polars-Linux-py3.11-pandas2.3.3-polars1.20.0 26.11% <100.00%> (+0.04%) ⬆️
unit-tests-polars-Linux-py3.12-pandas2.3.3-polars1.33.1 26.11% <100.00%> (+0.04%) ⬆️
unit-tests-polars-Linux-py3.13-pandas2.3.3-polars1.33.1 26.11% <100.00%> (+0.04%) ⬆️
unit-tests-polars-Linux-py3.14-pandas2.3.3-polars1.42.1 26.02% <100.00%> (+0.04%) ⬆️
unit-tests-polars-Windows-py3.10-pandas2.3.3-polars1.20.0 26.06% <100.00%> (+0.04%) ⬆️
unit-tests-polars-Windows-py3.11-pandas2.3.3-polars1.20.0 26.05% <100.00%> (+0.04%) ⬆️
unit-tests-polars-Windows-py3.12-pandas2.3.3-polars1.33.1 26.05% <100.00%> (+0.04%) ⬆️
unit-tests-polars-Windows-py3.13-pandas2.3.3-polars1.33.1 26.05% <100.00%> (+0.04%) ⬆️
unit-tests-polars-Windows-py3.14-pandas2.3.3-polars1.42.1 25.96% <100.00%> (+0.04%) ⬆️
unit-tests-polars-macOS-py3.10-pandas2.3.3-polars1.20.0 26.07% <100.00%> (+0.04%) ⬆️
unit-tests-polars-macOS-py3.11-pandas2.3.3-polars1.20.0 26.06% <100.00%> (+0.04%) ⬆️
unit-tests-polars-macOS-py3.12-pandas2.3.3-polars1.33.1 26.06% <100.00%> (+0.04%) ⬆️
unit-tests-polars-macOS-py3.13-pandas2.3.3-polars1.33.1 26.06% <100.00%> (+0.04%) ⬆️
unit-tests-polars-macOS-py3.14-pandas2.3.3-polars1.42.1 25.97% <100.00%> (+0.04%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

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

@cosmicbboy
cosmicbboy merged commit 7b894a6 into unionai-oss:main Oct 16, 2026
46 checks passed
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.

[Polars] Coercing pl.String into pl.Decimal sometimes loses accuracy

2 participants