Skip to content

feat: add null type for untyped nulls and empty collections - #1147

Open
nielspardon wants to merge 1 commit into
substrait-io:mainfrom
nielspardon:feat/null-type
Open

feat: add null type for untyped nulls and empty collections#1147
nielspardon wants to merge 1 commit into
substrait-io:mainfrom
nielspardon:feat/null-type

Conversation

@nielspardon

@nielspardon nielspardon commented Jul 29, 2026

Copy link
Copy Markdown
Member

Motivation

Substrait has no way to express a null that carries no type of its own. A producer whose source system models untyped nulls with a dedicated type — Spark NullType, Apache Arrow null, ClickHouse Nothing, Velox UNKNOWN — has to invent a concrete type at the boundary, and an empty array() or map() whose element type is genuinely unknown has no representation at all.

This is the alternative to #1140: following the community sync discussion, a dedicated null type rather than a documented producer convention for picking a concrete type. Adds Type.Null as Type.kind field 40, a type class whose only value is null.

Decisions worth reviewing

  • It is not a bottom type, and it does not coerce. This answers the standing objection that a bottom type would be inert under Substrait's strict no-coercion rule: null? stays distinct from i32?, and an explicit cast is required to use a null-typed value anywhere else. The alternative — implicit widening — would be the first coercion rule in the type system.
  • nullability is present, but constrained to NULLABILITY_NULLABLE. Every value of the type is null, so the field only ever has one legal value. It is kept anyway, rather than omitted as on Unbound, so that generic type-handling code sees the same shape as every other type class. Consequence: the type is written null? in the type syntax.
  • Distinct from unbound (feat: add unbound type for partially bound plans #1081). unbound means "not yet known, must be bound before execution"; null is fully resolved and executable, and simply has one value.
  • No new literal machinery. A bare null reuses the typed null literal; an untyped empty collection reuses empty_list / empty_map with null? as the element type.

Not a breaking change: buf breaking passes, and no previously legal plan changes meaning.

Context

This came out of the Apache Gluten ↔ Substrait fork-consolidation effort. Gluten carries a forked Type.Nothing for Spark's NullType / ClickHouse's Nothing (apache/gluten#791), which it has already relocated to field 40 — so Type.Null at 40 lets that fork converge by renaming the message rather than remapping field numbers. DataFusion hit the same gap and worked around it without a core type (apache/datafusion#15854, apache/datafusion#18414).

Supersedes #1140, now closed.

Open questions

  • Name. null reads best from SQL, but nothing (ClickHouse, Gluten) and unknown (PostgreSQL, Velox, Calcite) are the other established spellings.
  • type_variation_reference. Included for uniformity with the other type classes, though arguably meaningless for a single-valued type.
  • Cast semantics. A null-typed value casts only to a nullable target, and a cast to the null type succeeds only from null. Asserted in the docs, not enforced by the proto.

🤖 Generated with AI


This change is Reviewable

Add a `null` type class whose only value is null. It types a null that
carries no type information of its own — a bare SQL `NULL`, or a value
from a system that models untyped nulls with a dedicated type such as
Spark's `NullType`, Apache Arrow's `null`, or ClickHouse's `Nothing` —
as well as the element type of an empty list or map whose element types
are unknown.

The type is fully resolved and executable, unlike `unbound`, which marks
a type that is not yet known in a partially bound plan. Substrait's
strict type system applies unchanged: the `null` type does not coerce and
is not a bottom type, so an explicit cast is required to use a null-typed
value where another type is expected.

Because every value of the type is null, its nullability must always be
set to `NULLABILITY_NULLABLE`, and it is written `null?` in the type
syntax.

Changes:

- `Type.Null` message and `Type.null` oneof member (field 40).
- Clarify that `Expression.Literal.null` may carry the `null` type.
- Document the type class, its literal and empty-collection encodings,
  its non-coercing semantics, and producer guidance.
- Add `NULL` to the type grammar and the dialect schema.
- Add validated protobuf textformat examples covering a bare null, an
  untyped empty list, and the cast required to consume a null-typed
  value.
@nielspardon nielspardon added the apache-gluten Upstreaming features from the Apache Gluten Substrait fork label Jul 29, 2026
@nielspardon
nielspardon marked this pull request as ready for review July 29, 2026 18:55
// further binding. Substrait's strict type system applies to it as to
// every other type: null does not coerce, so an explicit cast is required
// to use a null-typed value where another type is expected.
Null null = 40;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Do we need a full Null type, or can we make the recommendation to set the type of the Typed Null to unbound?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

As I said on the community sync a fully resolved / bound null type is distinct from an unresolved / unbound type that additionally also has no nullability.

This also makes it easier for plan consumers to reject unbound plans via Substrait dialect but still support untyped null literals.

I prefer having a separate null type and not reuse the unbound type for this purpose.

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

Labels

apache-gluten Upstreaming features from the Apache Gluten Substrait fork

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants