feat: add null type for untyped nulls and empty collections - #1147
Open
nielspardon wants to merge 1 commit into
Open
feat: add null type for untyped nulls and empty collections#1147nielspardon wants to merge 1 commit into
nielspardon wants to merge 1 commit into
Conversation
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
marked this pull request as ready for review
July 29, 2026 18:55
nielspardon
requested review from
EpsilonPrime,
benbellick,
cpcloud,
jacques-n,
vbarua,
westonpace and
yongchul
as code owners
July 29, 2026 18:55
vbarua
reviewed
Jul 29, 2026
| // 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; |
Member
There was a problem hiding this comment.
Do we need a full Null type, or can we make the recommendation to set the type of the Typed Null to unbound?
Member
Author
There was a problem hiding this comment.
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.
9 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 Arrownull, ClickHouseNothing, VeloxUNKNOWN— has to invent a concrete type at the boundary, and an emptyarray()ormap()whose element type is genuinely unknown has no representation at all.This is the alternative to #1140: following the community sync discussion, a dedicated
nulltype rather than a documented producer convention for picking a concrete type. AddsType.NullasType.kindfield 40, a type class whose only value is null.Decisions worth reviewing
null?stays distinct fromi32?, 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.nullabilityis present, but constrained toNULLABILITY_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 onUnbound, so that generic type-handling code sees the same shape as every other type class. Consequence: the type is writtennull?in the type syntax.unbound(feat: add unbound type for partially bound plans #1081).unboundmeans "not yet known, must be bound before execution";nullis fully resolved and executable, and simply has one value.empty_list/empty_mapwithnull?as the element type.Not a breaking change:
buf breakingpasses, and no previously legal plan changes meaning.Context
This came out of the Apache Gluten ↔ Substrait fork-consolidation effort. Gluten carries a forked
Type.Nothingfor Spark'sNullType/ ClickHouse'sNothing(apache/gluten#791), which it has already relocated to field 40 — soType.Nullat 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
nullreads best from SQL, butnothing(ClickHouse, Gluten) andunknown(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.nulltype succeeds only from null. Asserted in the docs, not enforced by the proto.🤖 Generated with AI
This change is