Skip to content

Prune unread nested parquet leaves when a projected column is cast to a narrower type (nested schema pruning)#23398

Draft
adriangb wants to merge 4 commits into
apache:mainfrom
pydantic:nested-schema-pruning
Draft

Prune unread nested parquet leaves when a projected column is cast to a narrower type (nested schema pruning)#23398
adriangb wants to merge 4 commits into
apache:mainfrom
pydantic:nested-schema-pruning

Conversation

@adriangb

@adriangb adriangb commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Note

Stacked on #23396 (move-only refactor) and #23397 (benchmark). Until those merge, this PR shows their commits too; the feature itself is the last two commits (feat: prune unread nested parquet leaves... and bench: add pruning-disabled variants...), ~900 hand-written lines of which roughly half are tests. Supersedes #23392.

Which issue does this PR close?

Rationale for this change

When a table's logical file schema declares a nested column narrower than the physical parquet type — e.g. the table declares events: LIST<STRUCT<x, y>> over a file containing events: LIST<STRUCT<x, y, +16 more fields>> — DataFusion reads every leaf of the column and drops the extra subfields in memory:

  1. DefaultPhysicalExprAdapter rewrites the projected column into CAST(events AS narrow_type).
  2. The projection-mask derivation only understands literal get_field chains; the cast's inner column falls into the ProjectionMask::roots path, expanding to all physical leaves.
  3. The whole column is fetched and decoded; the runtime nested cast (nested_struct::cast_column) discards the unrequested subfields.

Engines like Spark communicate nested projection pruning to the scan exactly this way — as a clipped read schema, not as get_field expressions (Spark's SchemaPruning rule consumes the field-access expressions and bakes the result into the scan's requiredSchema). Comet measured a production query reading 1.35 TB where plain Spark read 30.9 GB for the same pruned ReadSchema. The same behavior is reproducible in pure DataFusion by declaring a narrower nested type in CREATE EXTERNAL TABLE and watching bytes_scanned. Any embedder that hands DataFusion a pre-pruned schema (Comet, delta-rs, Iceberg integrations) benefits.

This PR implements the equivalent of Spark's ParquetReadSupport.clipParquetSchema: when a projected root column is consumed only through such a cast, the leaf ProjectionMask is computed by walking the physical and cast-target type trees, matching struct fields by name, so only the leaves the cast retains are fetched and decoded. The adapter-inserted cast then degrades to a cheap reorder/rename/null-fill/promotion.

Why key off the cast target instead of the logical-vs-physical schema diff? The cast target is exactly the contract of the runtime nested cast: cast_column consumes source struct children only by looking up target field names, recursively through list wrappers, so physical subtrees the target never names are provably dead. It also works for custom PhysicalExprAdapters (which are allowed to inject references to physical-only columns — schema-driven clipping could under-read there, expression-driven clipping degrades safely to a full read), and it requires no opener signature changes. A bare Column whose logical type is narrower than the physical file is not a working configuration today, so threading the logical schema would add no coverage.

Benchmark results (see #23397; wide list<struct> file, narrow declared schema):

bytes_scanned wall time
narrow schema, pruning on 3.32 KB 164 µs
narrow schema, pruning off (= before this PR) 25.19 MB 2.94 ms
physically narrow file (floor) 3.32 KB 150 µs

The SUM(s['x']) shape on a schema-evolved struct — where get_field(CAST(col)) currently defeats the existing expression-level leaf clipping entirely (the bottom-up adapter rewrite means PushdownChecker no longer sees a plain Column under get_field) — improves ~80–90% as well, since it now clips to the cast target.

What changes are included in this PR?

On top of the stacked #23396/#23397:

  • New datafusion/datasource-parquet/src/nested_schema_pruning.rs: clip_for_cast walks physical vs cast-target types (name-matched struct children, case-sensitive to match cast_column; recurses through matching List/LargeList wrappers) and returns kept leaf offsets; prune_type_by_kept_offsets derives the Arrow type the reader emits for a kept-offset union. The clip is total — maps (no name-based Map arm in cast_column), dictionaries, wrapper-kind mismatches, and zero-name-overlap struct levels keep all their leaves, so the worst case is today's full read. Every struct level keeps ≥1 leaf, so struct definition levels (and s IS NULL semantics) survive.
  • PushdownChecker optionally collects CastColumnAccess (a CastExpr over a plain Column where requires_nested_struct_cast(physical, cast_type)), enabled only for projection analysis — the row-filter path is mechanically untouched.
  • build_projection_read_plan unions cast-clipped leaves with existing get_field leaf accesses per root; any whole-column reference wins; roots with only get_field accesses keep the bit-identical legacy path. A defensive leaf-count check falls back to a whole-root read if the arrow/parquet leaf mapping ever disagrees.
  • New config datafusion.execution.parquet.nested_projection_pruning (default true), threaded source → morselizer → opener → DecoderProjection, plus proto field + docs + information_schema entries. The flag exists purely as an escape hatch / bisection aid for a change in the IO path — there is no scenario where a correctly-working clip should be disabled — and could be deprecated after a release or two.
  • Pruning-disabled benchmark variants so the before/after stays visible in one run.

Are these changes tested?

Yes:

  • 16 unit tests in the new module, including an arrow-rs roundtrip test that pins the assumption this feature relies on: ProjectionMask::leaves over a subset of List<Struct> leaves emits exactly the predicted type and preserves null list rows / null struct elements.
  • 7 integration tests in datafusion/core/tests/parquet/expr_adapter.rs, each asserting identical results with the flag on and off and that bytes_scanned drops by more than 2×: list-of-struct narrowing (subset + reorder + Int32→Int64 leaf promotion + missing-subfield null-fill), top-level struct, struct-level nullability (s IS NULL), get_field on a narrowed struct, mixed whole-column + subfield access, filter pushdown enabled, and a scan mixing a physically-narrow and a wide file.
  • New datafusion/sqllogictest/test_files/parquet_nested_schema_pruning.slt covering the end-to-end SQL path (CREATE EXTERNAL TABLE with a narrower nested type) with the setting toggled both ways.
  • Full extended test suite passes locally.

Are there any user-facing changes?

A new configuration option, datafusion.execution.parquet.nested_projection_pruning (default true), documented in configs.md. No API changes; behavior is semantically invisible (IO reduction only).

Deliberately out of scope, noted for follow-ups:

  • Map key/value clipping (blocked on a name-based Map arm in nested_struct::cast_column).
  • Schema-driven clipping for the row-filter path (filters on evolved structs are not pushed down today, so that is a new capability needing its own analysis, not a regression).
  • Intersecting get_field paths with cast targets (naively unsafe: null-filling a non-nullable logical sibling changes per-batch struct-compatibility validation outcomes).
  • A pluggable clipping policy (field-id / case-insensitive matching for Iceberg-style embedders), mirroring the expr-adapter factory.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KuMaRtFSPDQesuzjN5Koyd

adriangb and others added 4 commits July 8, 2026 12:14
Registers the same wide list<struct> / struct parquet file with the
file's full schema and with a narrower declared schema, plus a
physically-narrow file as the floor. On main the narrow declared
schema reads exactly as many bytes as the full schema (the whole
column is fetched and clipped in memory by the adapter-inserted cast):

    list_struct_narrow_schema:      bytes_scanned=25.19 MB
    list_struct_full_schema:        bytes_scanned=25.19 MB
    list_struct_physically_narrow:  bytes_scanned=3.32 KB

    select_events_narrow_schema      3.45 ms
    select_events_full_schema        3.36 ms
    select_events_physically_narrow  164 µs

Baseline for schema-driven nested projection pruning
(see apache/datafusion-comet#4859).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KuMaRtFSPDQesuzjN5Koyd
Move-only: `ParquetReadPlan`, `StructFieldAccess`,
`build_projection_read_plan` and the leaf-index/schema-pruning helpers
move from row_filter.rs (2100+ lines) into projection_read_plan.rs.
`PushdownChecker`/`PushdownColumns` become pub(crate) so the new module
can keep using them. No behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KuMaRtFSPDQesuzjN5Koyd
…ast to a narrower type

When a table's logical schema declares a nested column narrower than
the physical parquet type (e.g. logical events: list<struct<x,y>> over
a file with list<struct<x,y,+18 more>>), the expr adapter rewrites the
projection into CAST(events AS narrow_type) — and the projection mask
derivation expanded that column to every physical leaf, fetching and
decoding subfields the cast then threw away.

This adds schema-driven nested projection pruning (the equivalent of
Spark's ParquetReadSupport.clipParquetSchema): when a projected root
column is consumed only through such a cast, the leaf ProjectionMask is
computed by walking the physical and cast-target type trees, matching
struct fields by name, recursing through matching List/LargeList
wrappers. Clipping keys off the cast target because that is exactly the
contract of the runtime nested cast (cast_column consumes source
children only by target-name lookup), so it applies to any
PhysicalExprAdapter that emits whole-column casts.

Safety/fallback rules (the clip is total; worst case = full read):
- maps, dictionaries, wrapper-kind mismatches: keep all leaves
  (cast_column has no name-based Map arm)
- struct level with zero name overlap: keep first child (>=1 leaf per
  level preserves struct definition levels, so `s IS NULL` semantics
  survive)
- leaf type promotion stays the cast's job: emitted types keep the
  physical leaf types
- unions with get_field accesses on the same root are supported; any
  whole-column reference wins

Gated by datafusion.execution.parquet.nested_projection_pruning
(default true).

Comet reported reading 1.35 TB where Spark read 30.9 GB for the same
pruned ReadSchema. On the parquet_nested_schema_pruning benchmark the
narrow-schema scan drops from 25.19 MB (full column) to the pruned
leaves only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KuMaRtFSPDQesuzjN5Koyd
Same narrow-schema tables queried with
datafusion.execution.parquet.nested_projection_pruning=false, so the
before/after stays visible in one run:

    select_events_narrow_schema                    164 µs / 3.32 KB
    select_events_narrow_schema_pruning_disabled  2.94 ms / 25.19 MB
    select_events_physically_narrow (floor)        150 µs / 3.32 KB

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KuMaRtFSPDQesuzjN5Koyd
@github-actions github-actions Bot added documentation Improvements or additions to documentation core Core DataFusion crate sqllogictest SQL Logic Tests (.slt) common Related to common crate proto Related to proto crate datasource Changes to the datasource crate labels Jul 8, 2026
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown

Thank you for opening this pull request!

Reviewer note: cargo-semver-checks reported the current version number is not SemVer-compatible with the changes in this pull request (compared against the base branch).

Details
     Cloning apache/main
    Building datafusion v54.0.0 (current)
       Built [ 100.621s] (current)
     Parsing datafusion v54.0.0 (current)
      Parsed [   0.032s] (current)
    Building datafusion v54.0.0 (baseline)
       Built [  99.462s] (baseline)
     Parsing datafusion v54.0.0 (baseline)
      Parsed [   0.035s] (baseline)
    Checking datafusion v54.0.0 -> v54.0.0 (no change; assume patch)
     Checked [   0.632s] 223 checks: 223 pass, 30 skip
     Summary no semver update required
    Finished [ 202.163s] datafusion
    Building datafusion-common v54.0.0 (current)
       Built [  32.656s] (current)
     Parsing datafusion-common v54.0.0 (current)
      Parsed [   0.059s] (current)
    Building datafusion-common v54.0.0 (baseline)
       Built [  32.547s] (baseline)
     Parsing datafusion-common v54.0.0 (baseline)
      Parsed [   0.060s] (baseline)
    Checking datafusion-common v54.0.0 -> v54.0.0 (no change; assume patch)
     Checked [   0.726s] 223 checks: 222 pass, 1 fail, 0 warn, 30 skip

--- failure constructible_struct_adds_field: externally-constructible struct adds field ---

Description:
A pub struct constructible with a struct literal has a new pub field. Existing struct literals must be updated to include the new field.
        ref: https://doc.rust-lang.org/reference/expressions/struct-expr.html
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.48.0/src/lints/constructible_struct_adds_field.ron

Failed in:
  field ParquetOptions.nested_projection_pruning in /home/runner/work/datafusion/datafusion/datafusion/common/src/config.rs:1103

     Summary semver requires new major version: 1 major and 0 minor checks failed
    Finished [  66.961s] datafusion-common
    Building datafusion-datasource-parquet v54.0.0 (current)
       Built [  41.738s] (current)
     Parsing datafusion-datasource-parquet v54.0.0 (current)
      Parsed [   0.031s] (current)
    Building datafusion-datasource-parquet v54.0.0 (baseline)
       Built [  41.824s] (baseline)
     Parsing datafusion-datasource-parquet v54.0.0 (baseline)
      Parsed [   0.030s] (baseline)
    Checking datafusion-datasource-parquet v54.0.0 -> v54.0.0 (no change; assume patch)
     Checked [   0.163s] 223 checks: 223 pass, 30 skip
     Summary no semver update required
    Finished [  84.755s] datafusion-datasource-parquet
    Building datafusion-proto v54.0.0 (current)
       Built [  57.380s] (current)
     Parsing datafusion-proto v54.0.0 (current)
      Parsed [   0.017s] (current)
    Building datafusion-proto v54.0.0 (baseline)
       Built [  57.458s] (baseline)
     Parsing datafusion-proto v54.0.0 (baseline)
      Parsed [   0.019s] (baseline)
    Checking datafusion-proto v54.0.0 -> v54.0.0 (no change; assume patch)
     Checked [   0.261s] 223 checks: 223 pass, 30 skip
     Summary no semver update required
    Finished [ 116.349s] datafusion-proto
    Building datafusion-proto-common v54.0.0 (current)
       Built [  20.795s] (current)
     Parsing datafusion-proto-common v54.0.0 (current)
      Parsed [   0.045s] (current)
    Building datafusion-proto-common v54.0.0 (baseline)
       Built [  21.000s] (baseline)
     Parsing datafusion-proto-common v54.0.0 (baseline)
      Parsed [   0.049s] (baseline)
    Checking datafusion-proto-common v54.0.0 -> v54.0.0 (no change; assume patch)
     Checked [   1.172s] 223 checks: 222 pass, 1 fail, 0 warn, 30 skip

--- failure constructible_struct_adds_field: externally-constructible struct adds field ---

Description:
A pub struct constructible with a struct literal has a new pub field. Existing struct literals must be updated to include the new field.
        ref: https://doc.rust-lang.org/reference/expressions/struct-expr.html
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.48.0/src/lints/constructible_struct_adds_field.ron

Failed in:
  field ParquetOptions.nested_projection_pruning in /home/runner/work/datafusion/datafusion/datafusion/proto-common/src/generated/prost.rs:849
  field ParquetOptions.nested_projection_pruning in /home/runner/work/datafusion/datafusion/datafusion/proto-common/src/generated/prost.rs:849
  field ParquetOptions.nested_projection_pruning in /home/runner/work/datafusion/datafusion/datafusion/proto-common/src/generated/prost.rs:849

     Summary semver requires new major version: 1 major and 0 minor checks failed
    Finished [  43.864s] datafusion-proto-common
    Building datafusion-proto-models v54.0.0 (current)
       Built [  23.846s] (current)
     Parsing datafusion-proto-models v54.0.0 (current)
      Parsed [   0.126s] (current)
    Building datafusion-proto-models v54.0.0 (baseline)
       Built [  23.881s] (baseline)
     Parsing datafusion-proto-models v54.0.0 (baseline)
      Parsed [   0.126s] (baseline)
    Checking datafusion-proto-models v54.0.0 -> v54.0.0 (no change; assume patch)
     Checked [   1.704s] 223 checks: 222 pass, 1 fail, 0 warn, 30 skip

--- failure constructible_struct_adds_field: externally-constructible struct adds field ---

Description:
A pub struct constructible with a struct literal has a new pub field. Existing struct literals must be updated to include the new field.
        ref: https://doc.rust-lang.org/reference/expressions/struct-expr.html
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.48.0/src/lints/constructible_struct_adds_field.ron

Failed in:
  field ParquetOptions.nested_projection_pruning in /home/runner/work/datafusion/datafusion/datafusion/proto-models/src/generated/datafusion_proto_common.rs:849
  field ParquetOptions.nested_projection_pruning in /home/runner/work/datafusion/datafusion/datafusion/proto-models/src/generated/datafusion_proto_common.rs:849

     Summary semver requires new major version: 1 major and 0 minor checks failed
    Finished [  50.644s] datafusion-proto-models
    Building datafusion-sqllogictest v54.0.0 (current)
       Built [ 174.521s] (current)
     Parsing datafusion-sqllogictest v54.0.0 (current)
      Parsed [   0.021s] (current)
    Building datafusion-sqllogictest v54.0.0 (baseline)
       Built [ 174.185s] (baseline)
     Parsing datafusion-sqllogictest v54.0.0 (baseline)
      Parsed [   0.022s] (baseline)
    Checking datafusion-sqllogictest v54.0.0 -> v54.0.0 (no change; assume patch)
     Checked [   0.094s] 223 checks: 223 pass, 30 skip
     Summary no semver update required
    Finished [ 351.283s] datafusion-sqllogictest

@github-actions github-actions Bot added the auto detected api change Auto detected API change label Jul 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auto detected api change Auto detected API change common Related to common crate core Core DataFusion crate datasource Changes to the datasource crate documentation Improvements or additions to documentation proto Related to proto crate sqllogictest SQL Logic Tests (.slt)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant