Skip to content

fix(conversions): reject a Decimal whose scale is negative - #3997

Merged
kevinjqliu merged 3 commits into
apache:mainfrom
Rodrigo-Palma:fix/decimal-negative-scale
Oct 24, 2026
Merged

kevinjqliu merged 3 commits into
apache:mainfrom
Rodrigo-Palma:fix/decimal-negative-scale

Conversation

@Rodrigo-Palma

@Rodrigo-Palma Rodrigo-Palma commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Closes #3996.

to_bytes for DecimalType compared abs(exponent) against the type's scale. A
Decimal carries the negated scale as its exponent, so a value with a negative scale
passed the check as if it had the matching positive one. decimal_to_unscaled then uses
only the digits, so the exponent is dropped and the value is written four orders of
magnitude off, with no error.

t = DecimalType(10, 2)
from_bytes(t, to_bytes(t, Decimal("1E+2")))   # 0.01, expected 100.00

Decimal("100").normalize() is exactly Decimal("1E+2"), so a normalized value, or one
that comes out of arithmetic that trims trailing zeros, hits this.

It matters beyond the round trip: to_bytes writes the lower_bounds and upper_bounds
of a data file (_write_data_file_statistics in pyiceberg/manifest.py) and is used again
in pyiceberg/io/pyarrow.py. A bound written as 0.01 instead of 100.00 makes scan
planning prune files that do hold matching rows, so a query returns fewer rows without
reporting anything.

The same abs(exponent) comparison is in DecimalLiteral.to and StringLiteral.to, so a
filter value like Decimal("1E+2") binds to decimal(10, 2) as if it were 100.00 while
keeping unscaled value 1. Bucket and truncate projections then prune the files that hold
matching rows: on a bucket(16, d) table, EqualTo("d", Decimal("1E+2")) returns 0 rows
where Decimal("100.00") returns 1.

Change

Compare the signed scale, -exponent, in to_bytes, DecimalLiteral.to and
StringLiteral.to, so a mismatching value raises the ValueError each already raises for
any other scale mismatch. Rescaling to the type's scale was the
other option, but that widens the contract of a function that today requires an exact
match, so this keeps the existing one.

Values with a positive scale are unaffected, and the error message is unchanged for them:
Decimal("123.4567") against decimal(7, 3) still reports : 4.

Tests

Two cases added to the existing test_raise_on_incorrect_precision_or_scale parametrization in
tests/test_conversions.py, one using the literal Decimal("1E+2") and one using
Decimal("100").normalize(). Both fail without the source change (DID NOT RAISE).

One case each added to test_decimal_to_decimal_conversion and
test_string_to_decimal_type_invalid_value in tests/expressions/test_literals.py, both
failing without the source change.

make lint and make test green locally (4188 passed, 5 skipped).

to_bytes compared abs(exponent) against the type scale, so a Decimal carrying
a negative scale passed as if it had the matching positive one. The exponent is
then dropped by decimal_to_unscaled, and the value is written four orders of
magnitude off without an error: Decimal('1E+2') stored in a decimal(10, 2)
reads back as 0.01.

Decimal('100').normalize() produces exactly that form, and to_bytes writes the
lower and upper bounds of a data file, so a wrong bound prunes files that do
hold matching rows.

Compare the signed scale instead.
@github-actions

Copy link
Copy Markdown

This pull request has been marked as stale due to 30 days of inactivity. It will be closed in 1 week if no further activity occurs. If you think that's incorrect or this pull request requires a review, please simply write any comment. If closed, you can revive the PR at any time and @mention a reviewer or discuss it on the dev@iceberg.apache.org list. Thank you for your contributions.

@github-actions github-actions Bot added the stale label Oct 19, 2026
@Rodrigo-Palma

Copy link
Copy Markdown
Contributor Author

@Fokko friendly ping: this is the companion to #3995 (same to_bytes path, negative-scale Decimal). CI is green and it still merges cleanly. Happy to adjust anything.

@github-actions github-actions Bot removed the stale label Oct 20, 2026

@rambleraptor rambleraptor left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Looks great, thanks for doing this!

Comment thread pyiceberg/conversions.py
# scale (1E+2) must not be read as if it had the matching positive one
value_scale = -int(exponent)
if value_scale != primitive_type.scale:
raise ValueError(f"Cannot serialize value, scale of value does not match type {primitive_type}: {value_scale}")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: I don't love this error message (decimal scale isn't always a term that people are immediately familiar with), but I think it'll be fine.

@Fokko Fokko 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.

Sorry for the late reply @Rodrigo-Palma, this should have been merged already 👍

@Fokko
Fokko added this pull request to the merge queue Oct 22, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to no response for status checks Oct 23, 2026
kevinjqliu and others added 2 commits October 23, 2026 22:45
DecimalLiteral.to and StringLiteral.to compared abs(exponent) against the
type's scale, so a filter value like Decimal("1E+2") bound to decimal(10, 2)
as if it were 100.00 while keeping unscaled value 1. Bucket and truncate
projections then pruned the files holding matching rows and the scan silently
returned fewer rows. Compare the signed scale so binding raises instead.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

@kevinjqliu kevinjqliu 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.

LGTM

I pushed a commit (c796d23) to fix a similar issue in pyiceberg/expressions/literals.py and add tests for error message

@kevinjqliu
kevinjqliu enabled auto-merge October 24, 2026 06:31
@kevinjqliu
kevinjqliu added this pull request to the merge queue Oct 24, 2026
Merged via the queue into apache:main with commit 46669fa Oct 24, 2026
21 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.

to_bytes silently rescales a Decimal with a negative scale

4 participants