Skip to content

Fix silent precision loss on unconstrained Postgres numeric columns - #720

Open
NubeDev wants to merge 2 commits into
datafusion-contrib:mainfrom
NubeDev:fix/postgres-unconstrained-numeric-scale
Open

Fix silent precision loss on unconstrained Postgres numeric columns#720
NubeDev wants to merge 2 commits into
datafusion-contrib:mainfrom
NubeDev:fix/postgres-unconstrained-numeric-scale

Conversation

@NubeDev

@NubeDev NubeDev commented Aug 14, 2026

Copy link
Copy Markdown

Which issue does this PR close?

Closes #719.

Rationale for this change

An unconstrained Postgres numeric column has no fixed scale — each row may carry a different number of decimal places — but the Arrow column needs a single scale. rows_to_arrow took that scale from the first non-null row and then Decimal::rescaled every later value down to it. rescale rounds, so longer values were silently corrupted:

SELECT v FROM (VALUES (20::numeric),(17.685::numeric),(15.334::numeric)) t(v)
-- returned 20, 18, 15

The same values in a different order came back exact, and a single-row result is always exact, so the loss survives casual testing. max(), min(), avg() and arithmetic over a numeric column all return this unconstrained type, so aggregates were the common exposure. Columns with a declared scale (numeric(10,3)) were unaffected — they take the schema-driven path.

What changes are included in this PR?

  • The Arrow scale for a numeric column that declares none is pinned to Decimal128(38, 20) instead of being inferred from the first row. That is the same scale schema inference already assigns to a bare numeric, so the row path and the schema path now agree, and the result no longer depends on row order or on how rows are chunked into batches.
  • Values are converted to the column's mantissa by exact i128 arithmetic (decimal_to_i128_mantissa) rather than Decimal::rescale, which rounds when narrowing and silently refuses to widen past its own 96-bit mantissa. A value that cannot be represented exactly at the column scale is now an error (DecimalExceedsColumnScale / DecimalOverflowsColumnScale) instead of a quietly rounded value.
  • numeric[] derived its scale from the first row's widest element and had the same order dependence; it is fixed the same way.
  • Columns with a declared scale keep taking their scale from the projected schema, unchanged.

Are there any user-facing changes?

Yes, and they are intended:

  • Unconstrained numeric columns now always come back as Decimal128(38, 20) rather than at whichever scale the first row happened to have. Values that were previously rounded are now exact.
  • A numeric value with more decimal places than the column's scale is rejected with an error instead of being silently rounded. Previously this loss was invisible.

No public API changes.

Testing

  • core/tests/postgres/mod.rs: test_postgres_unconstrained_numeric_precision runs the issue's repro through query_arrow with no projected schema and asserts both the column scale and every value. The fixture is deliberately ordered whole-number first (20, 17.685, 15.334) — ordered the other way it passes against the unfixed code. Verified failing before the fix (left: 0, right: 20, i.e. the zero-decimal worst case) and passing after.
  • crates/postgres/src/arrow_sql_gen/mod.rs: unit tests for decimal_to_i128_mantissa covering exact widening, an already-matching declared scale, refusal to round, and 128-bit overflow.
  • cargo fmt --all -- --check, cargo clippy -p datafusion-table-providers-postgres --all-features -- -D warnings, cargo test -p datafusion-table-providers-postgres --lib (57 passed), and the full Docker-backed Postgres integration suite cargo test -p datafusion-table-providers --features postgres --test integration postgres (21 passed) all clean locally.

🤖 Generated with Claude Code

An unconstrained `numeric` column has no fixed scale, but the Arrow
column needs one. The row path took it from the first non-null row and
then `rescale`d every later value down to it, and `Decimal::rescale`
rounds: `20, 17.685, 15.334` came back as `20, 18, 15`. Reordering the
same values made them exact, so the corruption depended on row order.
Aggregates (`max`, `avg`, arithmetic over `numeric`) return exactly this
unconstrained type, so they were the common exposure.

The scale is now pinned to `Decimal128(38, 20)` when the column declares
none — the same scale schema inference already assigns to a bare
`numeric`, so the row path and the schema path agree, and the result no
longer depends on which row arrives first or how rows are batched.
Declared scales (`numeric(10,3)`) keep taking their scale from the
projected schema as before.

Values are widened to the column scale by exact i128 arithmetic instead
of `Decimal::rescale`, which rounds when narrowing and silently refuses
to widen past its own 96-bit mantissa. Anything that cannot be
represented exactly at the column scale is now an error rather than a
quietly rounded value. `numeric[]` took its scale from the first row's
widest element and is fixed the same way.

The regression test's fixture is deliberately ordered whole-number
first (`20, 17.685, 15.334`); ordered the other way it passes against
the unfixed code.

Fixes datafusion-contrib#719
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@bjchambers bjchambers left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks for the bug report and the fix. A few minor comments / questions.

source: arrow::error::ArrowError,
},

#[snafu(display(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Can we put the column name in these two errors? It would make it easier for a user to understand and address.

///
/// This matches the scale schema inference already assigns to a bare `numeric`
/// (`Decimal128(38, 20)`), so the row path and the schema path agree.
const DEFAULT_UNCONSTRAINED_NUMERIC_SCALE: u32 = 20;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The constant should be shared between schema inference and this file to ensure they stay in sync.

… schema inference

`DecimalExceedsColumnScale` and `DecimalOverflowsColumnScale` now carry the column
name, so a user hitting either knows which column of their query is at fault rather
than only the offending value. The overflow message also reports the precision it
could not fit rather than hardcoding 38 in the text.

The default precision and scale for a `numeric` that declares none now live beside
schema inference as `schema::DEFAULT_NUMERIC_{PRECISION,SCALE}`, and both
`parse_numeric_type` and the row-decode path read them — the two agreeing is what
makes the fix work, so they can no longer drift apart silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@NubeDev

NubeDev commented Aug 14, 2026

Copy link
Copy Markdown
Author

Thanks for the review — both addressed in 676ef65.

Column name in the errors. DecimalExceedsColumnScale and DecimalOverflowsColumnScale now carry a column_name, threaded from column_names[i] at both the NUMERIC and NUMERIC_ARRAY decode sites, so a user hitting either sees which column of their query is at fault and not just the value:

The Postgres numeric value 1.235 in column 'reading' has more decimal places than the column scale 2; rescaling it would silently lose precision
Cannot represent the Postgres numeric value 79228162514264337593543950335 in column 'reading' as Decimal128(38, 20): the value overflows 128 bits

The overflow message also reports the precision it could not fit rather than hardcoding 38 in the text. The unit test asserts the column name is present in both.

Shared constant. DEFAULT_NUMERIC_PRECISION / DEFAULT_NUMERIC_SCALE now live next to schema inference in arrow_sql_gen/schema.rs, and both parse_numeric_type (which previously hardcoded (38, 20)) and the row-decode path in mod.rs read them. The two paths agreeing is exactly what makes this fix work, so this is worth pinning — thanks for catching it.

Re-ran here: cargo fmt --all -- --check clean, cargo clippy -p datafusion-table-providers-postgres --all-features -- -D warnings clean, --lib 57 passed, and the Docker Postgres integration suite 21 passed (including the new numeric regression case inside test_arrow_postgres_one_way).

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.

Postgres: unconstrained numeric column is rounded to the first row's scale (silent precision loss)

2 participants