Tested against the Rust reference implementation v0.93.0. For the rest of the API surface (reader, writer, scan, CLI), see reference.md.
The reader and inspector modules carry no transitive dependency on the writer module. A consumer that only needs to read Vortex files can depend on a strict subset:
<dependency> <groupId>io.github.dfa1.vortex</groupId> <artifactId>vortex-reader</artifactId> <version>0.20.0</version> </dependency> <!-- optional: inspector for layout-tree introspection --> <dependency> <groupId>io.github.dfa1.vortex</groupId> <artifactId>vortex-inspector</artifactId> <version>0.20.0</version> </dependency>
The zstd binding is optional on both sides: files using vortex.zstd need
io.github.dfa1.zstd:zstd plus io.github.dfa1.zstd:zstd-platform on the classpath (versions
pinned by the vortex BOM); without them, touching a vortex.zstd segment fails with a
VortexException that names the two artifacts. All other encodings are pure Java.
./mvnw -pl core,reader,inspector verify builds the read-only artifact set
without the writer module on the classpath. ReadRegistry.loadAll() registers
only the built-in decoders in reader; no encoder class is loaded.
| Item | Introduced | Java status |
|---|---|---|
DType::Union (fbs.DType.Type.Union = 12) |
Rust 0.71.0 | DType.Union (Rust's shape rules) and the vortex.union sparse union array (UnionArray). Not written: vortex.union is in no Rust edition. No interop test: vortex-jni's Arrow bridge rejects unions, so no Rust producer exists. |
vortex.onpair experimental string encoding |
Rust 0.74.0 | ✅ Read and written. In core2026.08.1, so default cascading writes offer it, as Rust's default compressor does; the trained dictionary is valid for Rust but not byte-identical to Rust's (Rust's sampling RNG is not portable). |
vortex.variant arbitrary nested objects |
Rust (vortex.parquet.variant) |
vortex.parquet.variant, the Apache Variant binary encoding vortex-jni's default writer uses for Arrow arrow.parquet.variant columns, as a struct of per-row metadata/value binaries; it does not interpret the Variant binary itself. Java writes variant columns of typed scalar values only (constant / chunked-of-constants core, optional shredded child), via vortex.variant; writing vortex.parquet.variant is not implemented (ADR 0014). |
| Arrow extension array import affecting Variant shape | Rust 0.74.0 (#8125) | Untested against the currently pinned v0.88.0 fixtures; #8125 not yet re-verified. |
vortex.dict layout over a values pool that is neither VarBin- nor primitive-shaped (e.g. a dict-encoded vortex.uuid, whose storage is FixedSizeList(U8, 16)) |
Not written by Rust: its dict layout admits only Primitive | Utf8 | Binary (dict_layout_supported), and its dict compressor schemes only integers, floats and strings |
DictAllTypesInteropIntegrationTest, which also fails if a Rust bump starts dict-encoding another type). A foreign file with such a pool fails with VortexException("unsupported dict values shape: …"). |
| Duplicate struct field names | Rust writer rejects ("StructLayout must have unique field names"); Rust reader tolerates foreign files (first-match access) | DType.Struct constructor rejects duplicate names at every nesting level with VortexException("duplicate field name"), where Rust's in-memory StructFields tolerates them. So Java can neither build such a schema to write nor read such a file — the name-keyed Chunk API cannot represent both columns, and silent column loss is worse than a loud failure on a file the reference writer refuses to produce. |
| Decimal values beyond their declared precision | Wire-legal (a decimal array stores whatever its unscaled integers hold). Rust's Arrow import rejects them from 0.91 (vortex-data/vortex#10897: "Arrow decimal import requires every valid value to be within the precision"); 0.90 kept them | ✅ Rust parity from 0.91: vortex-java's writer refuses a value with more digits than its column's precision, or more fractional digits than its scale (IllegalArgumentException naming the column and row, pinned by ChunkImplTest), whether the value comes through the API or a Parquet import. A value outside its declared type is wrong data, not a wider decimal. |
| Blank / control-character field names | Wire-legal; the Rust writer produces "" and whitespace-only names. NUL (U+0000) additionally aborts the Rust toolchain: Arrow FFI schema export hits a panic-cannot-unwind in arrow-rs (ffi_stream::get_schema) and SIGABRTs the process (measured against vortex-jni 0.75.0) |
IllegalArgumentException), and its reader rejects files carrying them (VortexException naming the producing pipeline as the likely bug) — the JSON-""-key principle: wire-legal is a floor, not a policy. Printable names of any shape ($-runs, spaces inside, emoji) are legal and round-trip intact both directions (measured; pinned by ColumnNameEdgeCasesIntegrationTest). |
Bulk loops (min/max, sums, run counting, widening, bit-packing, ...) live behind SimdOperations.
Two implementations exist: the default, plain Java loops shaped for C2's auto-vectorizer, and one written
with the incubating Vector API, selected only when the JVM is launched with
--add-modules jdk.incubator.vector (omitting the flag is the opt-out; the JVM prints
WARNING: Using incubator modules once). Both give Rust's results, pinned by a differential test:
floating-point sums are sequential (lane-wise accumulation would change the last bits), and the I64 sum
sums in vector lanes only when no prefix can overflow (every element in [-2^k, 2^k) with n * 2^k <= 2^63), otherwise it runs
Rust's checked add per element, so [MAX, 1, -1] is dropped in both. F16 sum and min/max are scalar in both.
Separate from the Vector API, found while comparing against the Rust source:
- Float min/max statistics order
-0.0before0.0and skipNaN, as Rust'stotal_comparewithskip_nansdoes. - Float filters order values totally, as vortex-jni does:
-0.0 < 0.0, andNaNequals itself and is the maximum (soNaN >= 0.0matches, where IEEE says it does not). Zone-map pruning follows: a float zone without anan_count(the legacyvortex.statstable) may hold aNaN, so it is never pruned on its maximum. Pinned byJavaWritesRustReadsIntegrationTest#javaWriter_jniReader_filteredFloats_matchJni(#518). - A float or decimal filter operand on an integer column is refused (
VortexException), as Rust's compare refuses operands of different dtypes. Integer operands of any width are still accepted, a deliberate leniency: Rust would refuse ani32literal against ani64column too. Pinned byFractionalOperandInteropIntegrationTest(#701). U64min/max and the dense-counter span inArrayStatsare computed in unsigned order, as Rust'styped_int_statsdoes.- The zoned float
SumskipsNaN, as Rust'sskip_nans()binding does; an all-NaNzone sums to0.0(#523).
Cross-implementation conformance against Raincloud
(tracked in #205): 430 curated public
datasets whose .vortex files are written by the Vortex Python bindings
(vortex-data 0.86.1, pinned via Raincloud v0.3.0) with a Parquet sibling built from the
same Arrow table. RaincloudConformanceIntegrationTest reads each hydrated .vortex with
vortex-java, exports CSV, and diffs it against the Parquet sibling read by hardwood (the
oracle) — a zero-diff proves every value survived the Python-write / Java-read boundary.
scripts/hydrate-raincloud-corpus.sh --max-mb 200 # cache → mirror → local build
./mvnw verify -pl integration -am -Dvortex.it.excludedGroups= -Dit.test=RaincloudConformanceIntegrationTestThe 96 tpcgen-rs-tpcds-* datasets are generated by tpcgen-cli, which Raincloud does not
install: build it once, with a Rust toolchain, from a tpcgen-rs checkout at the commit the
error message names (<version>+git.<commit>). The checkout's rust-toolchain.toml fetches the
pinned compiler; the installer drops the binary and the receipt Raincloud checks into the venv.
brew install rustup && export PATH="$(brew --prefix rustup)/bin:$PATH" git clone https://github.com/clflushopt/tpchgen-rs tpcgen-rs && git -C tpcgen-rs checkout <commit> ~/.cache/raincloud/conformance-venv-v0.3.0/bin/python -m raincloud.pipeline.generators.install_tpcgen --source tpcgen-rs
The conformance test proves values match; scripts/RaincloudCompare.java reports what they cost.
Per hydrated slug it prints the Parquet size, two Rust sizes (Raincloud's file, from the
vortex-data its release pins, and that file rewritten by vortex-jni at the Rust version
vortex-java mirrors), the Java size with default and compact write options, the import time,
and the full-scan time of the Rust file and of Java's, flagging a Java file over 2% larger than
vortex-jni's. It reports and never fails: size is information, not a reason to diverge from
Rust. vortex-jni rewrites the batches its scan yields, not the Parquet row groups Java imports,
so its chunk boundaries can differ slightly. It needs vortex-jni and Arrow, so it runs with the
integration module's test class path and Arrow's JVM flags:
./mvnw -q -pl integration -am -DskipTests package dependency:build-classpath \
-Dmdep.includeScope=test -Dmdep.outputFile=target/test-classpath.txt
java --enable-native-access=ALL-UNNAMED --add-opens java.base/java.nio=ALL-UNNAMED \
--sun-misc-unsafe-memory-access=allow -cp "$(cat integration/target/test-classpath.txt)" \
scripts/RaincloudCompare.java [slug ...] # no slug: the last manifestPer-slug status lives in integration/src/test/resources/raincloud/expected-status.csv
(ok must pass; gap:<issue> must still fail, so a fix flips the entry in the same change;
untriaged runs and reports without failing the build; missing_auth marks a slug that can't
even be hydrated without credentials this environment lacks — Kaggle, gated HuggingFace, etc. —
distinguishing "blocked on a credential" from plain untriaged). No CI job runs it: the corpus is
hydrated and checked locally. Current triage —
398 ok, 0 known gaps, 32 missing_auth (all Kaggle), 0 untriaged: on the v0.3.0 corpus, all
398 datasets that hydrate match their Parquet sibling exactly. Every gap found so far is fixed
(#206–#211,
#215–#217,
#221,
#225 RunEnd run-values validity,
#226 Sparse null fill / nullable patches,
#257 CsvExporter FixedSizeList/List support,
#259 conformance-test pipe deadlock,
#261 oracle repeated/list column support, plus
two bugs the widened oracle then caught: CsvExporter list offsets assumed I32/I64 only (any
integer width is wire-legal, mirroring VarBinArray), and ScanIterator could not slice a shared
ListArray/FixedSizeListArray spanning several split windows). The v0.3.0 bump
found three more, all fixed: vortex.bool bit offsets (#429), CSV export of
decimals (#430) and nulls in nullable decimal_byte_parts columns
(#431).
| Encoding ID | Decoder | Encoder | Decode | Encode | Notes |
|---|---|---|---|---|---|
vortex.primitive |
PrimitiveEncodingDecoder |
PrimitiveEncodingEncoder |
✅ | ✅ | All PType (I8–I64, U8–U64, F16, F32, F64) |
vortex.bool |
BoolEncodingDecoder |
BoolEncodingEncoder |
✅ | ✅ | Bool (bit-packed) |
vortex.null |
NullEncodingDecoder |
NullEncodingEncoder |
✅ | ✅ | Null |
vortex.bytebool |
ByteBoolEncodingDecoder |
ByteBoolEncodingEncoder |
✅ | ✅ | Bool (byte-per-element) |
vortex.zigzag |
ZigZagEncodingDecoder |
ZigZagEncodingEncoder |
✅ | ✅ | Signed integer PTypes |
vortex.constant |
ConstantEncodingDecoder |
ConstantEncodingEncoder |
✅ | ✅ | Primitive, Utf8, Binary, Bool, Null, Decimal, Extension; the cascade writes it for Primitive, Bool and Utf8 |
vortex.ext |
ExtEncodingDecoder |
ExtEncodingEncoder |
✅ | ✅ | Extension |
vortex.runend |
RunEndEncodingDecoder |
RunEndEncodingEncoder |
✅ | ✅ | Primitive, Utf8/Binary, Bool |
vortex.varbin |
VarBinEncodingDecoder |
VarBinEncodingEncoder |
✅ | ✅ | Utf8, Binary |
vortex.varbinview |
VarBinViewEncodingDecoder |
VarBinViewEncodingEncoder |
✅ | ✅ | Utf8, Binary |
vortex.alp |
AlpEncodingDecoder |
AlpEncodingEncoder |
✅ | ✅ | F64, F32 |
vortex.alprd |
AlpRdEncodingDecoder |
AlpRdEncodingEncoder |
✅ | ✅ | F64, F32 |
vortex.dict |
DictEncodingDecoder |
DictEncodingEncoder |
✅ | ✅ | Primitive, Utf8/Binary. Writes DictMetadata + children [codes, values], both cascaded (a float pool goes through ALP). With globalDict on (the default) the writer's data cascade never dictionary-encodes integers: Rust's file writer excludes IntDictScheme there and dictionary-encodes suitable columns once, as a layout. Any primitive column can qualify, as in Rust's dict_layout_supported, by Rust's probe: the cascade, run on the column's first 8,192 rows, must pick vortex.dict (Rust's DictStrategy). Text columns skip the probe, as our cascade picks OnPair where Rust's string dictionary wins, and need under half of those rows distinct. Neither has a cardinality cap: whenever the open dictionary reaches 65,535 entries or 1 MiB the writer closes it and starts the next, and a column of several dictionaries is a chunked layout of dict layouts, as in Rust. The codes are U16, or U8 over non-nullable 8-bit values, as Rust's dict builder sizes the codes for the most entries the values' range allows, a null entry included (a nullable 8-bit column may need 257). With cascading(0) there is no cascade to probe, so the distinct count of those rows (under half, and more than one) decides. With it off (the CSV and Parquet importers stream), integer chunks keep their per-chunk dictionary. A cascade dictionary (integer or float, e.g. a zone table's min column) orders its values as Rust's IntDictScheme/FloatDictScheme do, in the iteration order of their hashbrown + FxHash distinct-values map, so values and codes are byte for byte Rust's (#651). That order depends on hashbrown's group width (16 on x86, 8 elsewhere) once 16 buckets in a row are full, and Java follows the host's os.arch as a vortex-jni write on the same machine does. The dict layout and string dictionaries are built separately and keep first-seen order. Decode also accepts the pre-0.14.5 Java shape (1-byte metadata, [values, codes]) so older Java-written files stay readable; that shape was never readable by Rust |
vortex.sparse |
SparseEncodingDecoder |
SparseEncodingEncoder |
✅ | ✅ | Primitive, Bool, Utf8/Binary (decode); Primitive (encode) |
vortex.sequence |
SequenceEncodingDecoder |
SequenceEncodingEncoder |
✅ | ✅ | Primitive (encode: integer only, as Rust; decode also float) |
vortex.struct |
StructEncodingDecoder |
StructEncodingEncoder |
✅ | ✅ | Struct |
vortex.chunked |
ChunkedEncodingDecoder |
ChunkedEncodingEncoder |
✅ | ✅ | Primitive + Struct concat |
vortex.fsst |
FsstEncodingDecoder |
FsstEncodingEncoder |
✅ | ✅ | Utf8, Binary. The symbol table is the one Rust's fsst-rs 0.7 trains, byte for byte (#583): its sample, five generations, candidate gains, cut, tie order and final code order. A column with nulls keeps its validity as a third vortex.bool child and trains on its valid rows only, as Rust's fsst_compress and fsst_train_compressor do |
vortex.list |
ListEncodingDecoder |
ListEncodingEncoder |
✅ | ✅ | A nullable list's validity is its optional third child, as in Rust |
vortex.listview |
ListViewEncodingDecoder |
ListViewEncodingEncoder |
✅ | ✅ | Validity lives in the encoding's own fourth child slot, not under a vortex.masked wrapper. Read: both shapes decode. Write: only vortex.map's entries child takes this path — a plain nullable DType.List column still routes through vortex.masked + vortex.list |
vortex.map |
MapEncodingDecoder |
MapEncodingEncoder |
✅ | ✅ | Single entries child, a bare vortex.listview of non-nullable {key, value} structs (any other entries encoding is rejected, as in Rust); map validity is delegated to that child's validity slot. Single-flat columns only: neither ChunkedArrayCombiner nor ScanIterator's row-window slicing has a MapArray arm yet, so a chunked map column or a split-window scan fails as VortexException. Pre-existing gap shared with vortex.listview; tracked separately |
vortex.fixed_size_list |
FixedSizeListEncodingDecoder |
FixedSizeListEncodingEncoder |
✅ | ✅ | |
vortex.zstd |
ZstdEncodingDecoder |
ZstdEncodingEncoder |
✅ | ✅ | Primitive, Utf8, Binary |
vortex.masked |
MaskedEncodingDecoder |
MaskedEncodingEncoder |
✅ | ✅ | NullableData carrier. A nullable primitive or decimal column is not masked when cascading: as in Rust, its validity is a plain vortex.bool child of the encoding that stores the values (vortex.primitive, fastlanes.bitpacked, the run values of vortex.runend, the indices of fastlanes.rle, the codes of vortex.dict, the left parts of vortex.alprd, vortex.pco over the valid values only), passed down by fastlanes.for, vortex.zigzag and vortex.alp (which patches only valid exceptions), and one whose valid rows hold one value is a vortex.constant masked by a plain bitmap. A nullable Utf8 or Binary column is not masked when cascading either (including under a ColumnEncoding override): without a null it has no validity at all, as Rust's all-valid string array has (#650), and with nulls the text schemes compete on it with the validity in their own arrays, as Rust's do: vortex.fsst and vortex.onpair as their last child, vortex.varbinview and vortex.varbin too, vortex.zstd as its first, and a dictionary as one more, invalid, values entry with non-nullable codes, as Rust's dict builder writes it. One whose valid rows hold one value is a vortex.constant masked by a plain bitmap, and an all-null one a null constant. At cascade depth 0 nullable text is still masked. Other nullable columns: inner values cascade (Dict/FSST/ALP/…) when cascading is enabled; Utf8/Primitive still compete on measured size even at depth 0, else first-match FixedSizeList |
vortex.decimal |
DecimalEncodingDecoder |
DecimalEncodingEncoder |
✅ | ✅ | Precision 1–76 (i8 … i256 storage by precision), negative scales. Written from BigDecimal[]. A nullable column keeps its validity as an optional vortex.bool child, not a vortex.masked wrapper (#646) |
vortex.decimal_byte_parts |
DecimalBytePartsEncodingDecoder |
DecimalBytePartsEncodingEncoder |
✅ | ✅ | Write: one mantissa, no low parts, so a decimal is split only when every unscaled value fits a long (any precision, as Rust's narrowed_decimal finds from the data). As Rust's DecimalScheme, the mantissa is narrowed to the smallest of i8–i64 holding the values and cascaded (frame of reference, bit-packing, …); a chunk of fewer than two values stays vortex.decimal, as Rust leaves it un-narrowed. The byte parts are kept only when strictly smaller than the plain decimal, and a column of equal values is one vortex.constant. A nullable column keeps its validity on the mantissa child, or has none when every row is valid, rather than in a vortex.masked wrapper (#646) |
vortex.datetimeparts |
DateTimePartsEncodingDecoder |
DateTimePartsEncodingEncoder |
✅ | ✅ | |
vortex.pco |
PcoEncodingDecoder |
PcoEncodingEncoder |
✅ | ✅ | Decode: all modes. Encode: Classic + Consecutive (orders 1–7) delta + IntMult, in Rust's chunk/page layout and byte-identical to pco 1.0.1 for those; FloatMult/FloatQuant and Lookback deferred |
fastlanes.bitpacked |
BitpackedEncodingDecoder |
BitpackedEncodingEncoder |
✅ | ✅ | Unsigned integer PTypes. Patches are written as Rust's compress_patches leaves them: narrowed indices, values as a vortex.constant when all equal, and per-1024-value chunk offsets |
fastlanes.delta |
DeltaEncodingDecoder |
DeltaEncodingEncoder |
✅ | ✅ | Integer PTypes. In no edition: cascading writes offer it (bases/deltas cascaded, as Rust's DeltaScheme) only with WriteOptions.withoutEditions() |
fastlanes.for |
FrameOfReferenceEncodingDecoder |
FrameOfReferenceEncodingEncoder |
✅ | ✅ | Integer PTypes |
fastlanes.rle |
RleEncodingDecoder |
RleEncodingEncoder |
✅ | ✅ | Chunk-based RLE. Integers and floats (Rust's int and float RLE schemes); float runs compare raw bits, so -0.0 and NaN payloads round-trip. Cascades values/indices/offsets |
vortex.patched |
PatchedEncodingDecoder |
PatchedEncodingEncoder |
✅ | ✅ | Primitive PTypes; base + chunked patches (1024-elem blocks) |
vortex.variant |
VariantEncodingDecoder |
VariantEncodingEncoder |
✅ | ✅ | Canonical container; constant / chunked-of-constants core + optional shredded child. Typed-scalar values only — nested objects need parquet.variant (ADR 0014) |
vortex.union |
UnionEncodingDecoder |
— | ✅ | ❌ | Read only: sparse union, U8 type ids (nullable iff the union is) + one row-aligned child per variant; in no edition, so never written |
vortex.parquet.variant |
ParquetVariantEncodingDecoder |
— | ✅ | ❌ | Read only: decodes to a struct {metadata, value?, typed_value?} of Apache Variant binaries (Arrow's arrow.parquet.variant storage shape), masked by row validity. Writing is not implemented; Java writes typed-scalar variants through vortex.variant (ADR 0014) |
vortex.zstd_buffers |
ZstdBuffersEncodingDecoder |
— | ✅ | ❌ | Read only (zstd2026.02.0, opt-in in Rust): each buffer of any wrapped encoding is Zstd-decompressed at its declared alignment, then the inner encoding decodes with its own children. Needs the optional zstd binding, like vortex.zstd. Interop-tested against a Rust-written fixture (scripts/fixtures/zstd-buffers) |
vortex.onpair |
OnPairEncodingDecoder |
OnPairEncodingEncoder |
✅ | ✅ | Utf8, Binary; core2026.08.1, a cascade candidate under the default edition (competes with FSST, as in Rust) |
A flat segment's ArrayNode tree carries array stats, and Rust's flat layout does not compute them: it keeps
the ones the compressor had cached on the array, and each encoding passes on only some
(#583). The writer mirrors that for every node of the tree the compressor encodes, the root and the children
it cascades into alike:
| Column | Root encoding | Stats written |
|---|---|---|
| integer | vortex.primitive, fastlanes.bitpacked |
min, max, is_constant (only without nulls), null_count, uncompressed_size_in_bytes |
| float | vortex.primitive |
is_constant (only without nulls), null_count, uncompressed_size_in_bytes; no bounds |
| bool | vortex.bool |
is_constant, uncompressed_size_in_bytes |
| any | any other encoding (vortex.alp, vortex.alprd, vortex.fsst, vortex.onpair, vortex.constant, fastlanes.for, vortex.dict, strings, ...) |
none |
So a Java-written string column has no bound in its flat segments, as a Rust-written one has none:
VortexReader.columnStats() reports no string min/max for either, and a string bound lives only in the
zoned layout's stats table. A decimal column's min and max are decoded from the zone table too: columnStats() reports them and
zone-map pruning uses them, compared by value at the column's scale.
The nodes below the root follow the same table, plus three shapes of their own: a vortex.sequence carries
is_sorted and is_strict_sorted (false for a falling step), the primitive values of ALP, ALP-RD and bit-packed
patches carry is_constant = false, and the vortex.bool validity bitmap of a nullable primitive carries is_constant
when every row is valid (or none is) and otherwise min, max, sum (the valid-row count) and null_count.
NodeStatsInteropIntegrationTest compares every flat root, and the whole tree of the numeric columns, against
vortex-jni. The string columns' children (FSST lengths and codes, whose values depend on the trained symbol
table) are not covered, nor is the zone table of a column whose few bounds Rust's trained table compresses and ours does not.
Text is vortex.varbinview unless a scheme beats its size (vortex.varbin is a binary-only scheme, as in Rust), and that node carries is_constant, null_count and the size of its views
(#583).
Per ADR 0010 and ADR 0012, each decoder falls into one of three shapes:
- Zero-copy — output is a view over the memory-mapped file (or a wrapper over child arrays). No arena allocation, no per-element copy.
- Lazy — output is a
LazyXxxArray/ChunkedXxxArrayrecord that holds the encoded child plus the transform parameters. Per-rowgetXxx(i)applies the transform on demand. No output buffer is allocated unless a caller explicitly materializes viaArray.materialize(arena). - Materialized — output is a buffer allocated from
ctx.arena()populated duringdecode(). Required for decompression-style encodings (Bitpacked, Pco, Zstd, etc.) where reading elementiwould require decoding a window.
| Encoding ID | Now | Target | Notes |
|---|---|---|---|
vortex.primitive |
Zero-copy | Zero-copy | mmap slice |
vortex.bool |
Zero-copy | Zero-copy | mmap slice (bit-packed) |
vortex.null |
n/a | n/a | no per-row data |
vortex.bytebool |
Zero-copy | Zero-copy | LazyByteBoolArray — mmap slice read byte-per-row; bitmap on materialize |
vortex.zigzag |
Lazy | Lazy | LazyZigZagXxxArray (I8/I16/I32/I64); broadcast → LazyConstantXxxArray, ADR 0010 + 0015 |
vortex.constant |
Lazy | Lazy | LazyConstantXxxArray (primitive + bool + decimal) + VarBinConstantArray (Utf8/Binary); per-row broadcast, no buffer, ADR 0015 |
vortex.ext |
Zero-copy | Zero-copy | wraps storage |
vortex.runend |
Lazy | Lazy | LazyRunEndXxxArray (primitive + bool) + VarBinRunEndArray (Utf8/Binary); run resolved per access, no expansion, ADR 0015 |
vortex.varbin |
Zero-copy | Zero-copy | bytes + offsets slices |
vortex.varbinview |
Lazy | Lazy | VarBinViewArray — keeps views + data buffers as mmap slices |
vortex.alp |
Lazy | Lazy | LazyAlpXxxArray; broadcast → LazyConstantXxxArray; patched stays Materialized, ADR 0010 + 0015 |
vortex.alprd |
Lazy | Lazy | LazyAlpRdDoubleArray/LazyAlpRdFloatArray — left/right + patches on access |
vortex.dict |
Lazy | Lazy | DictXxxArray (numeric) + VarBinDictArray (string), ADR 0012. The vortex.dict layout resolves the same way for any VarBin-shaped values pool — a StringView pool, or any VarBin-backed extension dtype — normalizing a pool no longer than its codes whole and a longer one only up to the entries the codes reach, never the rows; a vortex.constant pool collapses to VarBinConstantArray |
vortex.sparse |
Lazy | Lazy | LazySparseXxxArray (primitive + bool) + VarBinSparseArray (Utf8/Binary); fill broadcast, patch resolved per access; patch-free range → VarBinConstantArray, ADR 0015 |
vortex.sequence |
Lazy | Lazy | LazySequenceXxxArray; base + i * multiplier per access, no buffer, ADR 0015 |
vortex.struct |
Zero-copy | Zero-copy | StructArray wraps fields |
vortex.chunked |
Lazy | Lazy | ChunkedXxxArray (primitive/Bool) + VarBinChunkedArray (Utf8/Binary), ADR 0012 |
vortex.fsst |
Lazy | Lazy | LazyFsstVarBinArray — per-row code range is independent, so getBytes(i) decompresses only row i; getByteLength/forEachByteLength read the uncompressed-lengths child directly, no decompression at all, ADR 0026 |
vortex.list |
Lazy | Lazy | ListArray wraps elements + offsets children; shape inherits from child; a validity child yields a MaskedArray over it |
vortex.listview |
Lazy | Lazy | ListViewArray wraps elements + offsets + sizes children; a validity child yields a MaskedArray over it |
vortex.map |
Lazy | Lazy | MapArray wraps the entries child (a ListViewArray, or a MaskedArray over one when the map is nullable) |
vortex.fixed_size_list |
Lazy | Lazy | FixedSizeListArray wraps flat elements child; no per-row alloc |
vortex.zstd |
Materialized | Materialized | block decompression |
vortex.masked |
Zero-copy | Zero-copy | wraps inner + validity |
vortex.decimal |
Lazy | Lazy | LazyDecimalArray — BigDecimal materialized per row on getDecimal(i) |
vortex.decimal_byte_parts |
Lazy | Lazy | LazyDecimalBytePartsArray — reassembles byte parts on access |
vortex.datetimeparts |
Lazy | Lazy | LazyDateTimePartsLongArray — reassembles parts on access |
vortex.pco |
Materialized | Materialized | range-encoded decompression |
fastlanes.bitpacked |
Materialized | Materialized | window unpacks bits |
fastlanes.delta |
Materialized | Materialized | cumulative sum requires sequential decode; output is one arena segment at the ptype's width, and only the chunks the row window touches are reconstructed |
fastlanes.for |
Lazy | Lazy | LazyForXxxArray (I8/U8/I16/U16/I32/U32/I64/U64), ADR 0010 + 0015 |
fastlanes.rle |
Lazy | Lazy | LazyRleXxxArray reads run values and the index table straight from their mmapped segments; validity → OffsetBoolArray; empty → LazyConstantXxxArray, ADR 0015 |
vortex.patched |
Materialized | Materialized | inner is full base + chunked patches (1024-elem blocks, lane-window-sorted); per-row access requires 2 laneOffsets reads + binary search inside the chunk window, so eager scatter wins for full scans |
vortex.variant |
Lazy | Lazy | container wraps constant/chunked core (inner-typed) + optional shredded child |
vortex.onpair |
Materialized | Materialized | codes walked in order into one VarBinOffsetArray |
vortex.union |
Lazy | Lazy | UnionArray over its decoded type ids and variant children |
Decompression-style encodings (Bitpacked / Pco / Zstd / Delta) stay Materialized by design —
element-at-i requires decoding a window, so they must allocate output (ADR 0010). Their output
can itself be wrapped in a 1:1 lazy transform (e.g. ALP over Bitpacked produces
LazyAlp(MaterializedXxx)). Fsst is the one exception: its per-row code range is independent of
every other row, so it stays Lazy instead (ADR 0026).
A scan filtered on a vortex.dict layout column skips the chunks of a dictionary none of whose
values satisfies the filter, when a null row cannot satisfy it either, deciding from the values
without reading a code. Rust evaluates a dictionary filter over the values too, but always reads the
codes: results are the same, but a corrupt codes buffer under such a filter goes unreported where
Rust fails the scan. This is a deliberate divergence, for WHERE col = 'x' with x absent from the
dictionary. The row test Compute.matcher(ScanFilter) builds for a string dictionary follows Rust:
the filter is evaluated once per dictionary value per scan, and u8/u16 codes are mapped to the
selected rows in one pass, Rust's values.take(codes).
Files containing unrecognized encoding IDs throw VortexException by default. The message names
which edition the id belongs to, or says the id is unknown to every edition if it
belongs to none. Opt
in to passthrough mode to read such files without failing:
ReadRegistry registry = ReadRegistry.builder() .registerDefaults() .allowUnknown() .build(); try (VortexReader vf = VortexReader.open(path, registry)) { // columns with unknown encodings are returned as UnknownArray }
Frozen, named, additive sets of encoding IDs, each carrying a forever read-compatibility
guarantee once frozen (ADR 0023) — a write-time/read-time policy, not part of the wire format.
WriteOptions.defaults() targets the latest frozen core edition; see
the reference doc for the writer/reader integration.
| Edition | Family | Adds |
|---|---|---|
core2025.05.0 |
core |
23 baseline: fastlanes.bitpacked/for, vortex.alp/alprd/bool/bytebool/chunked/constant/datetimeparts/decimal/decimal_byte_parts/dict/ext/fsst/list/null/primitive/runend/sparse/struct/varbin/varbinview/zigzag |
core2025.06.0 |
core |
vortex.pco, vortex.sequence, vortex.zstd |
core2025.10.0 |
core |
fastlanes.rle, vortex.fixed_size_list, vortex.listview, vortex.masked |
core2026.08.0 |
core |
no encoding; Rust adds the vortex.zoned layout, which the writer emits from this edition on (older targets get vortex.stats) |
core2026.08.1 |
core |
vortex.onpair |
core2026.08.2 |
core |
vortex.map |
core2026.08.3 |
core |
vortex.variant, vortex.parquet.variant (read only) — default write target |
preview2026.08.0 |
preview |
nothing yet |
zstd2026.02.0 |
zstd |
vortex.zstd_buffers (read only; buffer-level Zstd for GPU decode; declared by Rust's vortex-zstd plugin, opt-in) |
Mirrors Rust's vortex-edition declarations at the pinned vortex-jni release (0.93.0);
EditionCatalogParityIntegrationTest fails the build on drift. core editions are frozen with a
forever read-compatibility guarantee; preview holds opt-in components not yet adopted by core;
zstd is declared by Rust's vortex-zstd plugin crate rather than the core declarations, and is
never enabled by default.
Zone maps follow the edition too. A write targeting core2026.08.0 or later (the default)
emits Rust's vortex.zoned with fixed 8192-row zones and the aggregate set vortex-jni writes;
earlier targets emit the legacy vortex.stats, one zone per writeChunk batch. vortex-java
readers from 0.12.1 read vortex.zoned; before this release they placed a dictionary column's
vortex.zoned zones by its code chunks rather than the stride, which can mis-attribute zone stats
when the chunk count happens to equal the zone count, so read new files with this release or later.
Per-column encoder choice (WriteOptions.withColumnEncoding) mirrors Rust's
WriteStrategyBuilder::with_field_writer with a compressor restricted to chosen schemes, but is
narrower: a Rust field writer replaces the column's whole layout strategy, while vortex-java
keeps the column's zone map and chunking and only restricts its encodings.
fastlanes.delta and vortex.patched are in no edition: as in Rust, only a write with the guard
off (WriteOptions.withoutEditions()) may emit them. Rust editions also gate layouts, extension
dtypes and zone-map aggregates; vortex-java's catalog models array encodings only. (Upstream
additionally records each edition's minimum Rust Vortex release; that number belongs to a
different implementation's release train and isn't surfaced in any vortex-java API.)
Extension dtypes wrap a primitive storage array with a logical-id tag plus optional
metadata. The Rust catalog lives in
vortex-array/src/extension/;
each subdir below names a canonical extension id and its on-disk shape.
Read-side extensions live in io.github.dfa1.vortex.reader.extension (write-side encoders
implement ExtensionEncoder in the writer module). Both are registered on their registry, the
built-ins by registerDefaults(), as Rust registers an ExtVTable on the session. Each spec
extension is a singleton with typed decode methods on the concrete impl — grab the singleton
directly:
DType.Extension dtype = (DType.Extension) schema.field(ColumnName.of("birthdays")); List<LocalDate> values = DateExtensionDecoder.INSTANCE.decodeAll(dtype, chunk.column(ColumnName.of("birthdays")));
End-to-end round-trip — write a List<LocalDate>, read it back:
var schema = DType.structBuilder() .field(ColumnName.of("birthdays"), DateExtensionDecoder.INSTANCE.dtype(false)) .build(); writer.writeChunk(c -> c.put(ColumnName.of("birthdays"), dates)); // Collection auto-routed try (var iter = reader.scan(ScanOptions.all()); Chunk chunk = iter.next()) { List<LocalDate> back = chunk.as(ColumnName.of("birthdays"), LocalDate.class); }
Chunk.as(name, Class) decodes through the ExtensionDecoder registered for the column's id
(LocalDate ↔ vortex.date, LocalTime ↔ vortex.time, Instant ↔ vortex.timestamp,
UUID ↔ vortex.uuid, plus any registered third-party type). Both sides run the registered
validate(DType.Extension) — Rust's validate_dtype — on every extension dtype, at
VortexWriter.create and at open, and on the dtypes embedded in Variant metadata. The four
built-ins apply Rust's deserialize_metadata + validate_dtype rules
(ExtensionId.WellKnown#validate): vortex.date Days → I32, Milliseconds → I64;
vortex.time s/ms → I32, μs/ns → I64; vortex.timestamp metadata ≥ 3 bytes with a
UTF-8 zone and I64 storage; vortex.uuid 0–1 metadata bytes (a known version) over
FixedSizeList(non-null U8, 16). An extension dtype must carry its metadata field, empty when
there is none, as Rust requires; the writer always emits it. As in Rust, the metadata is parsed when
the file opens (a malformed one refuses the file) and kept typed; a timestamp's zone is kept as the
string the file stores, so a zone this JDK's database does not know still opens and round-trips,
and only TimestampMetadata#zone() parses it; an id nothing parses keeps its bytes (OpaqueMetadata, Rust's
ForeignExtMetadata).
ExtensionId is a sealed WellKnown/Custom type like EncodingId: the spec ids
(VORTEX_DATE, VORTEX_TIME, VORTEX_TIMESTAMP, VORTEX_UUID) plus any third-party id.
The ids the vortex-jni session registers by default are ExtensionId.WellKnown: besides the
four above, vortex.json and the vortex.st.* geometries (wkb, point, linestring,
polygon, multipoint, multilinestring, multipolygon, box). A file with one of them opens
without a decoder and the column reads as its storage — JSON text as Utf8, WKB as Binary,
geometries as GeoArrow coordinate structs and lists — validated with Rust's storage rules (the
spatial CRS metadata is not decoded). Java has no JSON or geometry domain type, so Chunk.as
refuses them.
As in Rust, opening a file with any other extension id that has no registered decoder fails
(VortexException; Rust: "No such DType extension ID"), unless
ReadRegistry.Builder#allowUnknown() is set — then the id round-trips verbatim through
DType.Extension's raw String field and the column reads as its storage array, Rust's
ForeignExtDType. vortex-jni 0.93.0 exposes no allow_unknown, so it cannot read a file with a
custom extension id at all. A blank id, or one with a control character, fails the read with
VortexException (Rust accepts any string).
| Extension id | Impl | Storage | Metadata | Round-trip |
|---|---|---|---|---|
vortex.date |
DateExtension |
I32 days (Arrow Date32) or I64 milliseconds (Date64) since 1970-01-01 |
1 byte: TimeUnit (Days or Milliseconds) |
✅ (writes Days) |
vortex.time |
TimeExtension |
I32 (s/ms) or I64 (μs/ns) since midnight | 1 byte: TimeUnit |
✅ |
vortex.timestamp |
TimestampExtension |
I64 epoch count in the recorded TimeUnit |
unit byte + u16 LE tz_len + UTF-8 tz | ✅ |
vortex.uuid |
UuidExtension |
FixedSizeList(Primitive(U8), 16) |
none | ✅ |
vortex.json |
none (storage) | Utf8 |
empty | storage |
vortex.st.* |
none (storage) | Binary (wkb) or GeoArrow coordinate Struct/List |
spatial protobuf (not decoded) | storage |
| custom ids | registered decoder | whatever the column declares | opaque bytes | ✅ when registered; storage with allowUnknown() |
TimeUnit (see extension/datetime/unit.rs)
encodes precision in the first metadata byte:
| Value | Unit |
|---|---|
| 0 | Nanoseconds |
| 1 | Microseconds |
| 2 | Milliseconds |
| 3 | Seconds |
| 4 | Days |
For unsupported extension ids the inspector falls back to a placeholder cell
(<GenericArray ext<vortex.X>>); the underlying storage array still decodes
correctly via the primitive accessors, callers just have to format the value
themselves.
Note: the oracle round-trip suite is pinned to
v0.88.0/(current Rust release). The bucket reuses identical fixture file names across versions but rewrites the bytes, so the local cache is version-keyed; bump theRustFixtures.VERSIONconstant in the integration tests and refresh this section when a newer set is published.
Cross-language round-trips tested against Rust-written fixture files hosted at
s3://vortex-compat-fixtures/v0.88.0/arrays/.
| Fixture | Status |
|---|---|
primitives.vortex |
✅ |
alp.vortex |
✅ |
bitpacked.vortex |
✅ |
booleans.vortex |
✅ |
constant.vortex |
✅ |
for.vortex |
✅ |
fsst.vortex |
✅ |
runend.vortex |
✅ |
sequence.vortex |
✅ |
varbin.vortex |
✅ |
struct_nested.vortex |
✅ |
null.vortex |
✅ |
bytebool.vortex |
✅ |
zigzag.vortex |
✅ |
datetime.vortex |
✅ |
dict.vortex |
✅ |
sparse.vortex |
✅ |
varbinview.vortex |
✅ |
chunked.vortex |
✅ |
rle.vortex |
✅ |
alprd.vortex |
✅ |
decimal.vortex |
✅ |
decimal_byte_parts.vortex |
✅ |
datetimeparts.vortex |
✅ |
list.vortex |
✅ |
listview.vortex |
✅ |
fixed_size_list.vortex |
✅ |
zstd.vortex |
✅ |
tpch_lineitem.compact.vortex |
✅ |
tpch_lineitem.regular.vortex |
✅ |
tpch_orders.compact.vortex |
✅ |
tpch_orders.regular.vortex |
✅ |
pco.vortex |
✅ |
clickbench_hits_5k.compact.vortex |
✅ |
clickbench_hits_5k.regular.vortex |
✅ |
masked.vortex |
❓ |
patched.vortex |
❓ |
variant.vortex |
✅ |
map.vortex |
✅ |