Conversation
deserialize_decimal and deserialize_fixed used trim_start_matches and trim_end_matches, which strip repeated occurrences and never require the prefix, so "decimal(decimal(5, 2)))))" and "fixed[16" both parsed successfully. Precision was also unbounded, so "decimal(50, 2)" was accepted into Schema and TableMetadata even though the crate's own MAX_DECIMAL_PRECISION is 38, and the failure only surfaced much later in Arrow or Avro conversion. Parse strictly with strip_prefix and strip_suffix, and reject precision outside 1..=38 and scale greater than precision. Signed-off-by: Jeremy Schoemaker <jeremy@shoemoney.com>
There was a problem hiding this comment.
Copilot review overview
🟢 Approval recommended
The changes are small, targeted, and well-tested, and the parsing hardening aligns with existing invariants while reducing downstream schema incompatibilities.
Review effort: Lite
Findings: 2
Open (2)
What changed in this PR
This PR hardens Iceberg primitive type string parsing for decimal(p, s) and fixed[n] during JSON schema deserialization to reject malformed inputs and enforce decimal invariants earlier in the pipeline.
Changes:
- Switch decimal and fixed parsing to strict
strip_prefix/strip_suffixparsing instead oftrim_*_matches. - Enforce decimal constraints at deserialization time (
0 < precision <= MAX_DECIMAL_PRECISION,scale <= precision) with clearer errors for malformed inputs. - Add unit tests covering rejected malformed strings and accepted valid strings.
| File | Description |
|---|---|
| crates/iceberg/src/spec/datatypes.rs | Tightens decimal/fixed string parsing and adds regression tests for malformed inputs. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| if precision == 0 || precision > MAX_DECIMAL_PRECISION { | ||
| return Err(D::Error::custom(format!( | ||
| "Decimals with precision larger than {MAX_DECIMAL_PRECISION} are not supported: {precision}" | ||
| ))); | ||
| } |
There was a problem hiding this comment.
Fixed in 7d88ecb. Zero precision now has its own error message; the regression test failed with the old message and passes with the fix.
| fn check_type_serde_roundtrip_value(json: &str, expected_type: Type) { | ||
| let parsed: Type = serde_json::from_str(json).unwrap(); | ||
| assert_eq!(parsed, expected_type); | ||
| } |
There was a problem hiding this comment.
Fixed in 7d88ecb. The helper now serializes the expected type and deserializes it again before comparing. All 1,759 iceberg library tests, nightly formatting, and crate Clippy passed.
mkroll-db
left a comment
There was a problem hiding this comment.
It matches mostly the go implementation and the difference in how negative numbers are handled is covered by tests.
Added some smaller suggestions to add more tests.
| r#""decimal(50, 2)""#, | ||
| r#""decimal(0, 0)""#, | ||
| r#""decimal(5, 8)""#, | ||
| r#""decimal(decimal(5, 2)))""#, | ||
| r#""decimal(5, 2""#, | ||
| r#""decimal(5, 2)))))""#, | ||
| r#""decimal(5, 2, 3)""#, | ||
| r#""decimal(-5, 2)""#, | ||
| r#""decimal()""#, | ||
| r#""fixed[fixed[16]]]""#, | ||
| r#""fixed[16""#, | ||
| r#""fixed[16]]]""#, | ||
| r#""fixed[]""#, |
There was a problem hiding this comment.
Suggestion for some more tests, in particular testing negative numbers (go has them excluded in the regex):
| r#""decimal(50, 2)""#, | |
| r#""decimal(0, 0)""#, | |
| r#""decimal(5, 8)""#, | |
| r#""decimal(decimal(5, 2)))""#, | |
| r#""decimal(5, 2""#, | |
| r#""decimal(5, 2)))))""#, | |
| r#""decimal(5, 2, 3)""#, | |
| r#""decimal(-5, 2)""#, | |
| r#""decimal()""#, | |
| r#""fixed[fixed[16]]]""#, | |
| r#""fixed[16""#, | |
| r#""fixed[16]]]""#, | |
| r#""fixed[]""#, | |
| r#""decimal(50, 2)""#, | |
| r#""decimal(0, 0)""#, | |
| r#""decimal(5, 8)""#, | |
| r#""decimal(decimal(5, 2)))""#, | |
| r#""decimal(decimal(5, 2)""#, | |
| r#""decimal(5, 2""#, | |
| r#""decimal(5, 2)))))""#, | |
| r#""decimal(5, 2, 3)""#, | |
| r#""decimal(-5, 2)""#, | |
| r#""decimal(5, -2)""#, | |
| r#""decimal(-5, -2)""#, | |
| r#""decimal(-2, -5)""#, | |
| r#""decimal((5, 2))""#, | |
| r#""decimal[5, 2]""#, | |
| r#""decimal()""#, | |
| r#""fixed[fixed[16]]]""#, | |
| r#""fixed[16""#, | |
| r#""fixed[16]]]""#, | |
| r#""fixed[[16]]""#, | |
| r#""fixed[[16]""#, | |
| r#""fixed(16)""#, | |
| r#""fixed[]""#, |
There was a problem hiding this comment.
Added the missing negative, nested and delimiter cases in efb7ebb. All 1,759 iceberg library tests pass, along with format and strict Clippy checks.
| precision: 5, | ||
| scale: 2, | ||
| }), | ||
| ), |
There was a problem hiding this comment.
Some more 'corner' case tests:
| ), | |
| ), | |
| ( | |
| r#""decimal(5, 0)""#, | |
| Type::Primitive(PrimitiveType::Decimal { | |
| precision: 5, | |
| scale: 0, | |
| }), | |
| ), | |
| ( | |
| r#""decimal(5, 5)""#, | |
| Type::Primitive(PrimitiveType::Decimal { | |
| precision: 5, | |
| scale: 5, | |
| }), | |
| ), | |
There was a problem hiding this comment.
Added decimal(5, 0) and decimal(5, 5) round trips in efb7ebb; both are covered by the passing library suite.
dannycjones
left a comment
There was a problem hiding this comment.
LGTM, thanks for picking this up!

Which issue does this PR close?
What changes are included in this PR?
deserialize_decimal(crates/iceberg/src/spec/datatypes.rs:350-363 on main) anddeserialize_fixed(:376-387 on main) usedtrim_start_matches/trim_end_matches.Those strip repeated occurrences and never require the prefix to be present, so the
following all parsed successfully before this change:
"decimal(5, 2)))))"->Decimal { precision: 5, scale: 2 }"decimal(decimal(5, 2)))"->Decimal { precision: 5, scale: 2 }"decimal(5, 2"(no closing paren) ->Decimal { precision: 5, scale: 2 }"fixed[fixed[16]]]"->Fixed(16)"fixed[16"->Fixed(16)Precision was also unvalidated, so
"decimal(50, 2)"deserialized into aPrimitiveType::Decimaland round-tripped back out, even thoughType::decimalandType::decimal_required_bytesenforceprecision <= MAX_DECIMAL_PRECISION(38). This isreachable from every JSON schema parse (
impl Deserialize for PrimitiveType), so aSchemaorTableMetadatawritten through iceberg-rust could carry a decimal type thaticeberg-java and pyiceberg reject; the error only surfaced much later and far away, in
arrow::schema(validate_decimal_precision_and_scale) oravro::schema.scale > precisionwas likewise accepted.
The fix parses strictly with
strip_prefix/strip_suffix, and applies the invariants thecrate already states elsewhere:
0 < precision <= MAX_DECIMAL_PRECISIONandscale <= precision.Not fixed deliberately: this PR does not introduce a regex dependency as the issue suggests,
since
strip_prefix+split_oncecovers the same grammar with no new dependency, and it doesnot touch the lenient
starts_with("decimal")dispatch inimpl Deserialize for PrimitiveType,which now simply routes malformed input to a clear error instead of silently accepting it.
Are these changes tested?
Yes, two new unit tests in the existing
spec::datatypestest module.Before the fix (test only, source pristine):
After the fix:
rustfmt(nightly-2026-04-16, per rust-toolchain.toml) andcargo clippy -p iceberg --lib --all-featuresare both clean on the touched file.AI Disclosure
Written in conjunction with my pair programmer Claude.
This review follow-up was prepared with AI assistance.