Skip to content

Parquet: Fix initial-default rows dropped when filtering on the defaulted column#16692

Open
cbb330 wants to merge 12 commits into
apache:mainfrom
cbb330:fix-16690-default-filter-drop
Open

Parquet: Fix initial-default rows dropped when filtering on the defaulted column#16692
cbb330 wants to merge 12 commits into
apache:mainfrom
cbb330:fix-16690-default-filter-drop

Conversation

@cbb330

@cbb330 cbb330 commented Jun 5, 2026

Copy link
Copy Markdown

What

A column added by schema evolution with an initial-default is backfilled with the default at read
time, but ParquetMetricsRowGroupFilter evaluates predicates against a column that is absent from
a data file as if it were all-null
. For a file written before the column existed, a predicate
referencing the column — including the IsNotNull engines infer for null-intolerant predicates —
skipped the row group, silently dropping exactly the rows the default backfills. The full scan
was correct, so the case was untested and unobserved.

Fixes #16690.

Repro (Spark 3.5, Parquet, format-version 3)

CREATE TABLE t (id bigint, name string) USING iceberg TBLPROPERTIES ('format-version'='3');
INSERT INTO t VALUES (1, 'Alice');                          -- written before column c exists
-- table.updateSchema().addColumn("c", StringType, lit("US")).commit();
INSERT INTO t VALUES (2, 'Bob', 'US');                      -- c present
Query Before After
SELECT id, c (1,US),(2,US) (1,US),(2,US)
WHERE c = 'US' (2) (1),(2)
WHERE upper(c) = 'US' (2) (1),(2)
WHERE c IS NOT NULL (2) (1),(2)
WHERE c = 'CA' () () ✅ (absent file still excluded)
WHERE c IS NULL () ()

Fix

The drop happens in ParquetMetricsRowGroupFilter, whose per-predicate handlers treat a column
missing from the file as all-null (// the column is not present and is all nulls). The filter is
already constructed with the table read schema, which carries initialDefault, so it has everything
it needs to be correct.

Override the single predicate dispatch point: when a predicate references a column that is missing
from the file but has an initialDefault, evaluate it against the default value
(pred.test(default)) instead of falling through to the null-assuming handlers. c = 'US'
ROWS_MIGHT_MATCH, c = 'CA'ROWS_CANNOT_MATCH, IsNotNull(c) → match, IsNull(c) → skip.
Columns present in the file, and missing columns without a default, are unchanged — so present-column
files still prune on real stats, and existing missing-column behavior is preserved.

This mirrors how the filter already gets the schema; it's a fix at the source rather than a rewrite
of the predicate at the call site.

Testing

  • TestMetricsRowGroupFilter#testColumnNotInFileWithInitialDefault (Parquet) — unit coverage for the
    fold: match/non-match/IsNull/IsNotNull/notEqual against the default.
  • TestDefaultValuesFilteredRead (Spark 3.5) — end-to-end; fails on main without the fix.
  • Full :iceberg-parquet:test, :iceberg-data:test TestMetricsRowGroupFilter (Parquet + ORC), and
    :iceberg-arrow:test pass.

Scope / notes

  • ParquetReader and VectorizedParquetReader both share this filter via ReadConf, so the row and
    vectorized read paths are both fixed.
  • The dictionary and bloom row-group filters do not skip a column missing from the file, so they need
    no change (verified by the end-to-end test).
  • Avro applies no file-level filtering (residual is applied by the engine after the default is
    injected), so it is already correct. ORC throws UnsupportedOperationException on reading an
    initial-default column, so there is no silent drop there.
  • The record-level filter path (ParquetFilters.convert) shares the same missing-column assumption
    but is only reached via the @Deprecated readSupport reader, not by any engine read; it can get
    the same treatment as a follow-up if desired.

@cbb330 cbb330 force-pushed the fix-16690-default-filter-drop branch from dbc0dff to bbafc90 Compare June 5, 2026 09:40
Comment thread parquet/src/main/java/org/apache/iceberg/parquet/ParquetFilters.java Outdated
…lted column

A column added by schema evolution with an initial-default is backfilled with
the default at read time, but ParquetMetricsRowGroupFilter evaluates predicates
against a column that is absent from a data file as if it were all-null. For a
file written before the column existed, a predicate referencing the column --
including the IsNotNull engines infer for null-intolerant predicates -- skipped
the row group, silently dropping exactly the rows the default backfills. The
full scan was correct, so the case was untested and unobserved.

Make the row-group metrics filter default-aware: when a predicate references a
column that is missing from the file but carries an initialDefault, evaluate it
against the default value instead of assuming null. The filter already receives
the table schema carrying the default, so this is a single check at the
predicate dispatch point. Columns present in the file, and missing columns
without a default, are unchanged.

Closes apache#16690

Signed-off-by: Christian Bush <chbush@linkedin.com>
@cbb330 cbb330 force-pushed the fix-16690-default-filter-drop branch from bbafc90 to 8326eb7 Compare June 5, 2026 20:33
@github-actions github-actions Bot added the data label Jun 5, 2026
@amogh-jahagirdar

Copy link
Copy Markdown
Contributor

I've gone ahead and added this to the 1.10.3 milestone since it's a correctness fix. I do think though this needs to be fixed differently by using the parquet schema as a source of truth rather than checking the stats.

…e in row-group filter

Determine whether a column is missing from a file from the Parquet file schema
rather than the column statistics. A column can be present in a file yet lack
stats, so the stats maps cannot decide presence. A predicate on a column absent
from the file with a non-null initial-default is evaluated against the default;
a column present in the file is filtered on its real values.

Adds a test that a present-but-no-stats column is filtered on stats rather than
evaluated against its default.
@amogh-jahagirdar

Copy link
Copy Markdown
Contributor

I published a PR to @cbb330 branch with a fix that uses the parquet schema and Iceberg schema fields with default values as a source of truth

}

@TestTemplate
public void testColumnNotInFileWithInitialDefault() {

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.

Since the unit test only asserts eq/notEq/isNull/notNull against the default, it might be worth adding one range and one set case for the absent-default column, e.g. in("country", "US", "MX") -> read and in("country", "CA", "MX") -> skip, plus lessThan("country", "AA") -> skip.

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.

Good call, I submitted a PR to @cbb330 branch cbb330#2 for this.

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.

Would be great to land this in prior to merge since it's a small change to the test case. It looks good to me.

@amogh-jahagirdar amogh-jahagirdar requested a review from pvary July 9, 2026 15:48
…ult column

The unit test only asserted eq/notEq/isNull/notNull against the default.
Range (lt/gtEq) and set (in/notIn) predicates dispatch through the same
predicate() fold, so cover them explicitly to guard the dispatch path.

Co-authored-by: Isaac

@amogh-jahagirdar amogh-jahagirdar 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.

I'll go ahead and approve this. I'll give it a day or so for any others to review cc @pvary @RussellSpitzer @stevenzwu @nssalian . If we're able to merge the test assertions by EOD today that's great, otherwise i think it's best to do a fast follow and in the backport we can include both. I'm largely trying to move the 1.10.3 release along as it's been some time.

…ter-tests

Parquet: Add range and set predicate coverage for absent initial-default column

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

thanks for fixing the tests @cbb330 and @amogh-jahagirdar.

StructType struct = schema.asStruct();
this.expr = Binder.bind(struct, Expressions.rewriteNot(unbound), caseSensitive);
this.initialDefaults = Maps.newHashMap();
for (Types.NestedField field : schema.columns()) {

@stevenzwu stevenzwu Jul 9, 2026

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.

schema.columns() walks only top-level fields. nested field will still have the same problem. Consider iterating all fields via TypeUtil.indexById(struct).

I am also wondering if we should just call schema.findField(id) inside predicate(). Since findField(id) internally uses lazyIdToField() with a cached map, it is unnecessary to keep another <id, initialDefault> here.

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.

Yeah we should just use the existing map to lookup by ID, no need for the extra mapping as it doesn't even save anything when we already need to keep all the field IDs in memory anyway. That addresses the nested field issue as well

}

@TestTemplate
public void testColumnNotInFileWithInitialDefault() {

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.

A few paths that flow through the new predicate() override aren't exercised here. All three can reuse this test's 'field id not in the file' pattern with fresh ids — no writer setup, no new fixture:

  • startsWith / notStartsWith on country: startsWith("U") should match, startsWith("X") should skip; symmetric for notStartsWith.
  • One non-string default. pred.test((T) initialDefault) receives the internal Iceberg representation — Integer for date, Long for timestamp, BigDecimal for decimal, ByteBuffer for binary. String hides that contract because Java value and internal repr coincide; e.g. an IntegerType field (id 100) with Literal.of(42) against lt/gt/eq would guard the unchecked cast against a future representation change.
  • A DoubleType field (id 101) with a non-NaN default — enables isNaN (skip) and notNaN (match), which the current coverage skips entirely.

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.

Thanks for the suggestion, Steven. Added all three:

  • startsWith/notStartsWith on country ("U" matches and "X" skips, with symmetric notStartsWith cases).
  • A DateType field using its raw Integer day-ordinal representation for lt/gt/eq, guarding the unchecked cast.
  • A DoubleType field with a non-NaN default covering isNaN (skip) and notNaN (match).

All follow the existing “field ID absent from the file schema” pattern with no new fixtures. Happy to add decimal or binary coverage if useful.

table.updateSchema().addColumn("c", Types.StringType.get(), Expressions.lit("US")).commit();
sql("REFRESH TABLE %s", tableName);

sql("INSERT INTO %s VALUES (2, 'Bob', 'US')", tableName);

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 would suggest also include a row with non-US values to cover the case that actual rows are read from the file

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.

Added (3, 'Eve', 'CA') alongside the existing US row in the same insert, so filtering exercises physically stored matching and non-matching values in addition to the initial-default path.

cbb330 and others added 3 commits July 9, 2026 14:07
Cover additional predicate types and physical non-default values to guard row-group pruning behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>
Use a date field to verify predicate evaluation handles its internal integer representation.

Co-authored-by: Cursor <cursoragent@cursor.com>

@stevenzwu stevenzwu 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. thanks for expanding the test coverage.

@stevenzwu

Copy link
Copy Markdown
Contributor

@cbb330 can you fix the conflict?

@cbb330

cbb330 commented Jul 10, 2026

Copy link
Copy Markdown
Author

@stevenzwu GitHub currently shows the PR as cleanly mergeable, and I can merge current main locally without conflicts. Are you still seeing a conflict? If so, could you point me to it?

Comment on lines +140 to +143
Types.NestedField field = schema.findField(id);
if (field != null && field.initialDefault() != null && !valueCounts.containsKey(id)) {
return pred.test((T) field.initialDefault()) ? ROWS_MIGHT_MATCH : ROWS_CANNOT_MATCH;
}

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.

How is this behaves with structs and defaults for structs?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

v3 initial-default rows are silently dropped when a query filters on the defaulted column

5 participants