Skip to content

[EPIC] Variant on DataFusion #26517

Open

Description

@raghuvanshraj

Is your feature request related to a problem or challenge?

OpenSearch is adding a Variant field type to its Parquet-backed indices. The query engine is DataFusion, the writer and Variant kernels are arrow-rs, and the SQL functions are datafusion-contrib/datafusion-variant. Files mix shredded and unshredded layouts within one table, and the same table is read by Spark.

This epic lists the core hooks that work still needs, following the split in #16116: generic extension points in core, Variant-specific implementations in the crate. It sits under #16116 and #21308. Items already tracked elsewhere are linked rather than restated; the new issues are the ones marked new.

Spelling the type in SQL (section 6) already has issues filed on DF:

They are linked here so the epic covers the whole core surface Variant needs.

Describe the solution you'd like

1. Read only the leaves a UDF needs

variant_get(v, '$.x') reads and decodes the whole Variant group because the Parquet planner only trusts get_field. Two PRs address this: #25013 (two hooks on ScalarUDFImpl) and draft #26282 (NestedInputAccess capability, also used by get_field, with row group and page pruning on the declared leaves). Our requirement for whichever shape lands: a function must be able to require more than one leaf for one argument (the typed leaf and the value fallback leaf), and pruning must keep any container that holds a fallback row. #26282 does both.

2. Keep the extension tag through planning

A Variant is a struct whose field metadata carries ARROW:extension:name = arrow.parquet.variant. Two core paths drop it, after which variant_get sees a plain struct and rejects the column.

3. Tables that mix shredded and unshredded files

When one file stores v as Struct<metadata, value> and another as Struct<metadata, value, typed_value: Struct<x: Int64>>, the merged table schema has typed_value, and the default adapter null-fills it for the unshredded file. The fill never touches the files; it is an in-memory rewrite of the read expression, done once per file at open, in DefaultPhysicalExprAdapter (physical-expr-adapter/src/schema_rewriter.rs):

  • Reading v whole: rewrite_column sees the file field differs from the table field and wraps the column in a CastExpr from the file struct to the table struct; the arrow struct cast adds typed_value as an all-null child at batch time.
  • Reading v['typed_value'] from a file that lacks it: try_rewrite_struct_field_access replaces the access with a null Literal; try_narrow_struct_cast does the same when the access goes through an adapter-inserted cast.
  • The batch path applies the same rewrite to every RecordBatch: BatchAdapter::adapt_batch.

For an ordinary struct that is correct. For Variant the shredding spec makes value = object, typed_value = null an invalid row, so a compliant reader returns NULL for every field of every row of that file, silently. Reproduced: a two-file table returned 0 of 1024 unshredded rows. Files shredded on different keys fail differently, at open, with target field is non-nullable but missing from source, raised from the same place (rewrite_column).

The constraint we would like core to hold: a file is read in its own layout and reconstructed, never null-filled into a layout it does not have. Two ways to satisfy it: per-file required_fields answers (#26282 evaluates them against each file's schema at open, but states that "adapter-inserted struct casts are handled as before", so it narrows the read without removing the cast; whether variant_get then sees the file struct or the cast struct is to be confirmed), or a hook for a registered extension type to supply its own per-file adaptation, which satisfies the constraint by construction. The Variant adapter itself would live in the crate. Named earlier on #16116 (2025-07-15) and #22908 (2026-09-08).

4. Statistics the optimizer can use

ColumnStatistics is flat, so a nested leaf (shredded or not) and any UDF output have no planner statistics. Two designs are open for the UDF half and one has to be picked; #26282 adds a container-level output_statistics for pruning, which is a third data point.

5. Variant value semantics

GROUP BY, DISTINCT, joins and ORDER BY on a Variant column are undefined today. Spark rejects all of them at analysis time: OrderUtils.isOrderable returns false for VariantType, which fails =/< and join keys in BinaryComparison.checkInputDataTypes (DATATYPE_MISMATCH.INVALID_ORDERING_TYPE), ORDER BY in CheckAnalysis (EXPRESSION_TYPE_IS_NOT_ORDERABLE), GROUP BY (GROUP_EXPRESSION_TYPE_IS_NOT_ORDERABLE), and set operations in CheckAnalysis, where the comment reads "remove this type check once we support Variant ordering". DuckDB 1.5 allows them but does not document the rule (Variant docs cover storage, variant_typeof, extraction and Parquet only); probed on 1.5: =, GROUP BY, DISTINCT and ORDER BY each apply a different rule (equal numbers of different width compare equal but group apart; ORDER BY is by inner type tag, so 7.0 sorts after 42).

  • new, after design discussion A canonical-bytes normalisation kernel in arrow-rs so equal values have equal bytes, and a variant_normalize function in the crate on top of it, so hashing operators work without each inventing a rule. For ORDER BY our lean is to refuse at plan time as Spark does. Issue to follow once the SQL semantics are written up.

6. Spelling VARIANT in SQL

The generic machinery is merged: ExtensionTypeRegistry and DFExtensionType (#20312, DataFusion 54), TypePlanner (#20676, 54), and metadata-preserving CAST (#23169, 55.1.0 via backport #24875). What is missing is the Variant entries. Without them a Variant prints as raw struct bytes in datafusion-cli, and CREATE TABLE t (v VARIANT) or CAST(x AS VARIANT) cannot be planned. Spark 4.2 and DuckDB 1.5 both spell the type VARIANT in CREATE TABLE and CAST, so this is what lets a query written for them run unchanged.

Adjacent, in arrow-rs

Describe alternatives you've considered

Keeping all of this inside OpenSearch (a fork of the Parquet reader and a private variant_get). Rejected: every hook above is generic to extension types, and the mixed-layout and statistics problems are hit by any Variant reader on DataFusion, not just ours.

Additional context

Compatibility target for the crate work is Spark 4.2.0 (datafusion-variant#69). We build against DataFusion 54 today and are moving to 55 / arrow-rs 59, which the crate requires.

Open questions for maintainers:

  1. Are items 2 and 3 accepted as core concerns (neither mentions Variant), with the Variant adapter shipped from the crate?
  2. For item 1, which shape: feat: expose UDF field access and input requirements for Parquet pruning #25013, feat: NestedInputAccess capability for nested read pruning and row group / page pruning #26282, or a merge of the two?
  3. For item 4, ProjectionExec produces unknown statistics for all ScalarFunctionExpr outputs #21307 or Add ExpressionAnalyzer for pluggable expression-level statistics estimation #21122?

Activity

  1. sdf-jkl commented on Oct 20, 2026

    @sdf-jkl
    Member

    Thanks @raghuvanshraj this is epic!

    Compatibility target for the crate work is Spark 4.2.0 (datafusion-variant#69). We build against DataFusion 54 today and are moving to 55 / arrow-rs 59, which the crate requires.

    I updated datafusion-variant to arrow-60 here -- datafusion-contrib/datafusion-variant#92

    Spark also added some more functions in 4.3 which I'm planning to track soon

    1. Spelling VARIANT in SQL

    datafusion-variant has a workaround for spelling VARIANT in SQL. Supporting CAST is tracked here -- datafusion-contrib/datafusion-variant#90

  2. raghuvanshraj commented on Oct 21, 2026

    @raghuvanshraj
    Author

    Thanks @raghuvanshraj this is epic!

    Compatibility target for the crate work is Spark 4.2.0 (datafusion-variant#69). We build against DataFusion 54 today and are moving to 55 / arrow-rs 59, which the crate requires.

    I updated datafusion-variant to arrow-60 here -- datafusion-contrib/datafusion-variant#92

    Spark also added some more functions in 4.3 which I'm planning to track soon

    1. Spelling VARIANT in SQL

    datafusion-variant has a workaround for spelling VARIANT in SQL. Supporting CAST is tracked here -- datafusion-contrib/datafusion-variant#90

    Thanks @sdf-jkl ! Going through your linked issues.

  3. raghuvanshraj commented on Oct 21, 2026

    @raghuvanshraj
    Author

    @alamb I have some ideas around this that we would like to run by you some time. I've been able to prepare a plan for core and contrib contributions along with arrow-rs for E2E variant support on DF.

  4. alamb commented on Oct 23, 2026

    @alamb
    Contributor
  5. alamb commented on Oct 26, 2026

    @alamb
    Contributor

    We had a good discussion about this today -- in general I think the technical items listed above all make sense and would make good API additions to DataFusion.

    In terms of gathering sufficient maintainer interest / attention to add the new APIs, I suggested that we focus on the usecases.

    For example, for

    1. Read only the leaves a UDF needs

    I think it would really help to spell out the high level usecase, something like


    We reading variant data stored in parquet. When we run a query like this to extract field f from a variant column, even if f is shredded, DataFusion currently reads the entire variant column

    SELECT variant_col:f from my_table;

    Here is the full reproducer

    // TODO: rust code that shows how to reproduce this

    DataFusion already does this for struct fields (TODO links to examples / showing how this works), I want the same thing for variant

  6. alamb commented on Oct 26, 2026

    @alamb
    Contributor
  7. paleolimbot commented on Oct 27, 2026

    @paleolimbot
    Member

    Pluggable cast resolution to and from extension types

    I'll try to circle back here as soon as I can (although I am also happy to review an attempt if somebody gets there first!)

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions