Repository navigation
Description
This is a tracking issue for making Vortex's extension types sound and consistent.
- Subissue of Epic: Harden Type System #10730 and Epic: Extension Types #7683.
Design
Arguably the most important feature of Vortex is its extensibility. We allow extension points in many places, including the type system. However, logical extension types have always been somewhat unwieldy, where we constantly find ourselves running into edges and corners. This is mostly because our understanding of what an extension type should be is generally different from what we have actually implemented, and that causes bugs and other issues.
So this issue is a place where we can more formally define the design and ideas behind extension types, and we can gradually make changes to get closer to our idealized vision of extension types.
Extension types are opaque
This is probably the most apparent change that we need to make. When we wrap an extension type around a storage type, the extension type should not inherit any behavior from the storage type unless it explicitly opts in. The only way to get to the storage is to ask for it with ext_storage.
In Rust, a close example to this is a newtype, where we wrap an existing type with a tuple struct with a single argument. Unless we mark that argument as pub, this type is fully opaque. If we want to forward behavior to the inner type (like fields, methods, and trait implementations), we must manually implement that logic (it does not happen for free).
However, doing this for every shared behavior is somewhat cumbersome and tedious, so ideally we want to have ways to conveniently forward logic. See #11115 for more details on a proposal about this.
Top-level nullability
An extension type can't choose its top-level nullability: it must accept both a nullable and a non-nullable storage dtype, and its validator can't depend on which one it gets. #11012 made this change. Note that validators can still constrain nested nullability, since that belongs to the type (see the next section).
This is subtle because the validator sees the whole storage dtype, top-level nullability included, so it looks like a type should be able to reject one of them. But Vortex changes the top-level nullability of arrays all the time without changing any values: take with nullable indices and mask both make an array nullable, and min_max builds non-nullable result dtypes. Before #11012, each of these validated the type again and panicked if the type rejected the new nullability.
A top-level null means the whole value is missing, which says nothing about what the type means. Whether a column can have missing values is a property of the column, the same as for every built-in dtype. Nested nulls are different, because they're part of a value.
The storage declaration in the next section turns this from a documented rule into a structural one: storage_dtype takes no nullability at all, and ExtDType::try_new(metadata, nullability) applies the top-level nullability to the declared storage dtype itself, so a type can't depend on it even by accident.
Extension metadata rework
Right now, an extension type is handed a storage dtype and decides whether to accept it (ExtVTable::validate_dtype). We want to change this so that each extension type declares its storage dtype from its metadata, the same way an encoding declares the dtypes of its children.
This is how array deserialization already works in Vortex, but the more important reason is related to nested nullability in extension storage types. We have decided that an extension type over nested storage must choose exactly which of its inner levels can be null, because that's part of what the type means.
For example, a LineString is a list of non-null coordinates, and the elements of a Vector are never null. On the other hand, a range type like Postgres' int8range, stored as a struct of lower and upper bounds, must declare both bounds as nullable, since a null bound means the range is unbounded on that side. [5, inf) is a real range, and declaring the bounds non-nullable would make it impossible to represent. A range with two null bounds (the range that contains everything) is still a valid, non-null value, which is different from a null range.
This means that we are constraining extension types to only allow 1 kind of nullability per level in nested storage. Please let us know if this breaks your use case.
Here is an updated (simplified) vtable:
pub trait ExtVTable {
fn storage_dtype(metadata: &Self::Metadata) -> DType;
fn validate_metadata(metadata: &Self::Metadata) -> VortexResult<()> { Ok(()) }
fn deserialize_metadata(&self, bytes: &[u8], stored_storage: &DType) -> VortexResult<Self::Metadata>;
}This implies that metadata holds every fact that decides the storage. Some of those facts only live in the storage dtype today, like the element type and dimension of Vector, the element type of FixedShapeTensor, and the dimension of Point and Rect, so they will need to move into the in-memory metadata.
Note that none of this changes the file format, since the serialized metadata stays the same. It does change the ExtVTable API, and reads will reject a file whose stored storage dtype doesn't match the declaration, so a file in any other form will need a read plugin.
Steps
- Fix the bugs that give wrong results or panics today, so that every encoding and read path gives the same answer
- Declare the storage dtype from the metadata, and read only the declared storage
- Reject every semantic operation that no declaration or type rule covers, with hardcoded opt-ins for the extension types in the repository
- Let each extension type own its casts and other rules
- Keep the extension type at every boundary (Arrow, DataFusion, DuckDB, Python, JNI)
- Check extension values when arrays are built or imported
- Document the contract for extension type authors
The first two steps can happen in parallel, and the third needs only the first. #11115 later replaces the hardcoded opt-ins with claims. Most of the later steps build on the third, but the value checks and most of the boundary work only need the storage declaration. Within the steps, a few PRs have to land in order, because each one alone breaks something that works today. The Arrow export has to land before casts stop reinterpreting storage, since callers that ask Arrow for a storage-like type (such as Int64 for a timestamp column) would otherwise lose their only lossless path. The spatial nullability fix has to land before the storage declaration, which makes reads strict. A check on 2026-11-03 found that no Vortex producer wrote a spatial dtype with a nullable nested level. Only storage built by hand through the public Rust API can have one, so such files stop reading, and no read plugin is needed.
PR plan (agents keep this list up to date)
Each line is one PR of the implementation plan, whose dependency graph uses the same IDs. Part 4 of the notes describes U1 to U9, and the decisions record the design (X1 to X35). #11115 has the claims (CL) and the storage paths (SP). When a PR opens, add its link. When it merges, tick its box. When its scope changes, add a short note here. Record a change of plan in the research branch only when it changes a decision or the dependency graph.
Fix the bugs that give wrong results or panics:
- PC Path-consistency test helper. Needs: none. Add execution path consistency assertions #11013, merged on 2026-10-30
- U5 Errors for malformed temporal dtypes and out-of-range temporal values. Needs: none. Return errors for invalid temporal types and values #11015, merged on 2026-10-30
- U4 + U8 Nullability changes without validating again, and the
Debugoutput ofExtDTypeRef. Needs: none. Change extension dtype nullability without validating again #11012, merged on 2026-11-02 - U2 Matching dtypes for every comparison, including the
DateTimePartskernel. Needs: PC. Require matching dtypes for extension comparisons #11016, merged on 2026-11-02, together with DF - DF DataFusion comparison and
IN-list literals keep their extension type. Needs: U2. Preserve extension literals in DataFusion comparisons #11067, merged on 2026-11-02 - U3 + U6 + U7 Storage dtype checks for arrays and scalars, read errors instead of panics, and checked zero values. Needs: none. Validate extension storage and return scalar read errors #11014, merged on 2026-11-02
- TS
Timestampaccepts everyi64(X31), andDateTimePartswidens the days toi64when they don't fit ini32. Needs: none. fix(array): accept timestamps beyond jiff's range #10953, an outside contribution with maintainer commits, merged on 2026-11-03 - V10 The
DisplayofDateprints a fallback for values that jiff can't hold, instead of panicking (EXT-V10). Needs: none. Display dates beyond jiff's range without panicking #11187, open draft - SF A dtype error in a lazily parsed struct field returns an error instead of panicking. Needs: none
- DS The DataFusion physical schema checks the storage of an extension column against the layout of its logical type, so
Vectorcolumns andJsoncolumns declared asUtf8can be scanned. Needs: none - AX Arrow and DataFusion export a type without a plugin as its storage with
ARROW:extension:nameandARROW:extension:metadata, and import Vortex IDs (X24). Needs: none. Must land before U1 - U1 One cast rule on both paths: no cast reinterprets storage, and the Arrow exporters call
ext_storageor fail. Needs: PC, AX
Declare the storage dtype from the metadata:
- SN1 The spatial validators require non-null nested levels (EXT-N2, X26). No Vortex producer wrote a spatial dtype with a nullable nested level (only hand-built storage could), so such files stop reading. Needs: none. Must land before SD. Reject nullable nested levels in list-based spatial types #11183, open draft
- DP Arrow import peels dictionary fields before it calls the importer plugins, so dictionary-encoded extension columns stop relying on the plugins declining them. Needs: none. Peel Arrow dictionaries before Arrow importer plugins run #11185, open draft
- SN2 Every Arrow importer of a known type (the spatial types,
Vector,Uuid, andJson) converts children that are marked nullable but hold no nulls, and fails on data outside the domain of the type instead of declining it (X29). Needs: DP - SD
storage_dtypereplacesvalidate_dtype, withvalidate_metadataand adeserialize_metadatathat receives the stored storage dtype. The facts that only live in the storage move into the metadata, the generic extension cast changes only the top-level nullability, and reads require that the stored storage dtype equals the declared one (X27 to X30). Needs: U4, U5, SN1. Declare extension storage dtypes instead of validating them #11184, open draft, stacked on Reject nullable nested levels in list-based spatial types #11183 -
SR Strict reads: the stored storage dtype must equal the declared one (X29). Needs: SD, SN1Merged into SD, since a declaration without the check reads an old form silently wrong - NU
ExtensionArray::new_unchecked,ExtVTable::validate_array, and a conformance harness for the metadata round trip. Needs: SD
Reject semantic operations that nothing covers:
- FD
ExtensionUsewithNone,Structural, andSpecific, andextension_useon scalar functions and aggregates, with the defaultNoneand no change of behavior. Also the hardcoded lists of the types whose storage gives their equality and their order, in one place (X17, X35). Needs: none. AddExtensionUseand the storage equality and order lists #11186, open draft - TY1 Compare and
betweenanswerSpecificfor the types on the lists. Needs: FD. Coordinate with Push between on extension arrays down to storage #10408 - TY2 The aggregates that read values answer
Specificfor the types on the lists. Needs: FD. Coordinate with Move aggregates out of vortex-array into vortex-aggregates #10769 - TY3 Structural functions answer
Structural. Needs: FD - TY4
list_containsanswersSpecificfor elements on the equality list. Needs: FD - TY The typing check where nodes are built, which rejects every call that answers
None, privateBoundExpressionvariants, a fallibleCast::new, zone map writers without the storage fallback, and DuckDB aggregate pushdown through the lists. Needs: TY1 to TY4, U1, U2, DF - ST1 Readers ignore stored min and max of types outside the order list, with one warning for each file and column (X22, X35). Needs: FD
- U9
ExtensionStoragePushdownwraps its results in the extension dtype, with a fixed mapping forMinMaxandIsSorted(X12). Needs: none. Coordinate with Move aggregates out of vortex-array into vortex-aggregates #10769. Return extension values fromExtensionStoragePushdown#11188, open draft
Let each extension type own its rules:
- RU1
ExtVTable::rulesoverReduceNode(X20). Needs: TY - RU2 Exact unit casts to a finer unit, and a named function with a rounding mode for a coarser unit (C1, C2, Casting #8570). Needs: RU1. Coordinate with feat(array): add
Durationextension dtype #10467 and feat(array): add Interval extension dtype #10468 - RU3 The DuckDB and DataFusion converters send extension literals, and a SQL cast that truncates maps to the named function. Needs: RU2
Keep the extension type at every boundary (AX is in the first group, because it lands before U1):
- BD1 Converters for DuckDB, Python and JNI. Needs: none
Check extension values:
- VC1
ExtensionArray::try_newchecks every non-null value, and kernels that keep values legal move tonew_unchecked(X7). Needs: U3, NU - VC2 Imports check values, and an optional strict read mode checks file data (X23). Needs: VC1
Document the contract:
- BD3 A typed array view, a native value accessor, metadata helpers, and the developer guide page for extension types. Needs: none
Low priority, outside the steps above:
- LP1 Lift generic structural encodings above the extension wrapper (X5, XQ1, Keep generic structural encodings above other encodings #10748). Needs: none
- LP2 Shared pushdown through
MaskedandSparse, and a constant-fold hook on scalar functions (X34). Needs: none. Reduce constant projections through select and extension storage #11056 (merged on 2026-11-03) is the constant case forext_storage - LP3
DateTimePartsunder the wrapper as ani64encoding, behind an edition. Needs: SP3 (Tracking Issue: Extension Type Derives #11115), whose rule it replaces - LP4 Registration stops replacing and duplicating silently (EXT-R2). Needs: none
Related PRs outside this plan:
- Move aggregates out of vortex-array into vortex-aggregates #10769 moves the aggregate implementations into
vortex-aggregates. U9, TY2, and SP4 (Tracking Issue: Extension Type Derives #11115) change the same code. Open draft - feat(spatial): write geometry bounding boxes to file statistics #10837 adds spatial bounding boxes to the file statistics, and changes the aggregate vtable that FD changes. Open
- Push between on extension arrays down to storage #10408 pushes
betweendown for temporal types. TY1 declares it with the hardcoded lists, and SP3 (Tracking Issue: Extension Type Derives #11115) moves it to the claims. Open draft - feat(array): add
Durationextension dtype #10467 and feat(array): add Interval extension dtype #10468 addDurationandInterval, and change the converters that U1, U2 and RU2 change. Open - feat(array): add IsCompoundSorted agg fn #10661 adds
IsCompoundSorted, which compares keys withScalar::partial_cmp. Open - Prove comparisons true for every row from statistics #10686 proves comparisons true from statistics. Open draft
- Optimize arrays only with a session; keep constructors lazy #10793 removes
optimizewithout a session, which changes where type rules can run (X20). Open
Unresolved questions
- Open question: which generic structural encodings (
Constant,Dict,RunEnd,Sparse,Chunked,Masked,Interleave) should sit above the extension wrapper, and should a canonical extension array always wrap canonical storage? See Keep generic structural encodings above other encodings #10748. - Open question: what should
PartialEq,PartialOrd, and a futureHashonScalarmean for extension values? Expression results won't depend on them, but Rust code like maps of constants and tests still does.
Implementation history
- feat(aggregate): add ExtensionStoragePushdown to push an aggregate into an extension type's storage #10566 Add
ExtensionStoragePushdownto run an aggregate on the storage of an extension type (2026-10-20) - feat(aggregate): register is_sorted and min_max for temporal and UUID extension types #10616 Register
is_sortedandmin_maxstorage pushdown for the temporal types andUuid(2026-10-20) - Return errors for invalid temporal types and values #11015 Return errors for invalid temporal types and values (2026-10-30)
- Add execution path consistency assertions #11013 Add execution path consistency assertions (2026-10-30)
- Change extension dtype nullability without validating again #11012 Change extension dtype nullability without validating again (2026-11-02)
- Require matching dtypes for extension comparisons #11016 Require matching dtypes for extension comparisons (2026-11-02)
- Preserve extension literals in DataFusion comparisons #11067 Preserve extension literals in DataFusion comparisons (2026-11-02)
- Validate extension storage and return scalar read errors #11014 Validate extension storage and return scalar read errors (2026-11-02)
- Document temporal storage and binary comparison execution #11099 Document temporal storage and binary comparison execution (2026-11-02)
- Reduce constant projections through select and extension storage #11056 Reduce constant projections through
selectand extension storage (2026-11-03) - fix(array): accept timestamps beyond jiff's range #10953 Accept every
i64timestamp, including values beyond jiff's range, and widenDateTimePartsdays that don't fit ini32(2026-11-03)
Activity
Name the nodes of the extension dependency graph after the PR plan of #…
Move the claims and storage paths of the extension plan to #11115