Apache Parquet
VecRuntime reads and writes Parquet natively, straight between Parquet's on-disk layout and the Arrow
buffers its operators run on, with no row conversion in between. The native reader
(VectorParquetScanExec) decodes Parquet pages directly into those buffers; the columnar writer
(VectorWriteFilesExec / ColumnarParquetWriter) encodes them straight back out. Both are on by default,
and both fall back to Spark — per file or per plan — for anything they do not yet handle, always returning
Spark's own result.
This page is the single reference for what each side supports and how to configure it. For the design, benchmarks and the full test matrix, see the two detailed pages:
- Native Parquet reader — the scan's architecture, read-ahead, how reads are issued, the whole-corpus conformance suite, and the performance work list.
- Columnar Parquet writer — the encoder layout, nested writes, bloom filters, partitioned and bucketed writes, and the write benchmarks.
The reader
spark.vecruntime.scan.nativeParquet.enabled replaces Spark's FileSourceScanExec with a scan that decodes
Parquet directly into Arrow buffers, reusing the partitions, splits, bucketing, pushed filters and schema
Spark already computed. It is on by default, except in a session where Comet's scan is active (Comet's
plugin or extension registered with spark.comet.enabled and spark.comet.scan.enabled not false), where
Comet's reader stays the scan unless the key is set explicitly. A column or file it cannot decode falls over
to Spark's own vectorized reader for that one file, or keeps Spark's scan for the whole plan — the result is
always Spark's.
What it reads
Physical storage (checked per file at open, physicalMatches): INT32, INT64, FLOAT, DOUBLE, BOOLEAN,
BINARY, FIXED_LEN_BYTE_ARRAY (decimals, binary, UUID), and INT96 timestamps. Unsigned integers (UINT_32
read as bigint zero-extended, UINT_64 as decimal(20,0)), and INT32/INT64 read as decimals wider than 18
digits.
Logical / Spark types. boolean, tinyint, smallint, int, bigint, float, double, date,
timestamp and timestamp_ntz (INT64 MICROS/MILLIS, or INT96 for timestamp), decimal to precision 38,
string (UTF8; a uuid column stored as FLBA(16) with the UUID logical type reads as its canonical-text
string, #rtypes), binary, time (any INT64, nanoseconds of the day), and the ANSI intervals (interval day to second on INT64, interval year to month on INT32). variant is decoded too — unshredded, shredded
at the top level, and with top-level extraction pushed into the scan — as the struct of its value and
metadata binaries.
Nested types. Structs, lists and maps nested inside one another at any depth, in Spark's three-level
LIST/MAP form, the legacy two-level forms, and parquet-java's: lists and maps of flat types, lists of
structs, lists of lists, maps of structs, and any other combination (NativeParquetTreeReader).
Encodings (supportsEncoding): PLAIN, PLAIN_DICTIONARY, RLE_DICTIONARY, RLE (booleans and levels),
BIT_PACKED (levels); and per physical type DELTA_BINARY_PACKED (INT32/INT64), DELTA_LENGTH_BYTE_ARRAY
(BINARY), DELTA_BYTE_ARRAY (BINARY and FIXED_LEN_BYTE_ARRAY), and BYTE_STREAM_SPLIT
(INT32/INT64/FLOAT/DOUBLE and FIXED_LEN_BYTE_ARRAY). Dictionary pages are kept as dictionaries: a string
column's dictionary-encoded batches are emitted as dictionary ids over the row group's Arrow dictionary
(dictionaryStrings, on by default), resolved to bytes only where a page falls back from the dictionary.
Compression codecs. Whatever parquet-java decompresses, byte for byte as Spark's own reader: uncompressed,
snappy, zstd, gzip, lz4, lz4_raw. zstd and snappy decompress through one reused buffer per column chunk and are
read in place; the others are parquet-java's own decompressors. The codec is never a fallback reason — a file in
any codec whose decompressor is on the classpath is read natively. BROTLI and LZO have no codec on the
classpath (LZO is GPL, Brotli ships with neither aircompressor nor parquet-java), so a file in either fails with
parquet-java's BadConfigurationException exactly as Spark's reader would.
Pruning and page skipping. Row-group statistics, dictionary filters and bloom filters prune row groups
through parquet-java (readNextFilteredRowGroup). The column index (per-page min/max, offset index) skips
pages outside a filter's row ranges — off by default (parquet.filter.columnindex.enabled): on the classic
S3A stream every selected page is its own GET, and at 1 TB that cost 37%; it waits for range merging (#834) to
pay back. Late materialization (lateMaterialization, off by default) decodes a filter's columns first and
the others only at surviving rows; its two-pass mode (lateMaterialization.twoPass, off by default) also
skips the page I/O of row groups' non-surviving rows.
Read-ahead. The scan opens files and reads row groups ahead on background threads while the task thread
decodes (prefetchFiles, prefetchRowGroups), bounded in bytes per task and per executor and charged to Spark
execution memory (#915). Decode-ahead (decodeAhead, off by default) runs decoding on a producer thread;
parallel decode (parallelDecode, off by default) decodes later row groups on helper threads. Small-file
prefetch (smallFileThreshold) and range merging (rangeMergeGap) issue fewer, larger GETs (#834) — both
off by default until a 1 TB A/B sets the thresholds.
Other. Every calendar rebase mode (CORRECTED, LEGACY, EXCEPTION), resolved per file; INT96 with its
own rebase and time-zone conversion; legacy dates. Field-id matching
(spark.sql.parquet.fieldId.read.enabled). Schema evolution — missing columns and fields (null, or an
existence DEFAULT), type widening, field-id matching — matching Spark's readers. Hive bucketing (Spark's own
bucketed file partitions, so a bucketed join needs no shuffle). The _metadata columns (file_path,
file_name, file_size, file_block_start, file_block_length, file_modification_time,
_metadata.row_index) and input_file_name() with its block siblings.
The writer
spark.vecruntime.write.columnar.enabled writes a Spark Parquet write (df.write.parquet, INSERT into a
Parquet table, CREATE TABLE ... USING parquet AS SELECT) straight from our columnar batches, with no row
conversion and no parquet-java ParquetWriteSupport. It is on by default (#982). Spark keeps the write
command, the commit protocol, file naming, statistics and the table refresh; only the task changes. parquet-java
is kept for the footer, the column and offset indexes, and compression.
What it writes
Parquet v2 only — data page v2, and the v2 encodings. There is no v1 code path; a write that asks for v1
(parquet.writer.version=v1) keeps Spark's writer. Every v2 reader (Spark, ours, parquet-java, Trino, DuckDB)
reads the files.
Encodings, the same choices as parquet-java's v2 writer, so files compare page by page in tests, with one
deliberate exception — BOOLEAN is PLAIN, not the RLE hybrid parquet-java picks for v2, because Iceberg's
vectorized Spark reader rejects an RLE boolean column. Dictionary (RLE_DICTIONARY) with a per-type fallback:
INT32/INT64 fall back to DELTA_BINARY_PACKED; DOUBLE/FLOAT/INT96 to PLAIN; BINARY and FIXED_LEN_BYTE_ARRAY
to DELTA_BYTE_ARRAY. The dictionary falls back when it outgrows parquet.dictionary.page.size or does not pay
after the first page.
Statistics, page indexes and bloom filters are written as parquet-java writes them (min/max compare
unsigned for binary and fixed, signed two's-complement for decimals; INT96 carries only a null count). Bloom
filters honour parquet.bloom.filter.enabled (global or #<column>) with expected.ndv, fpp, max.bytes,
adaptive.enabled and candidates.number; an all-dictionary chunk gets no filter, as with parquet-java.
Sizing. Row-group, page and dictionary-page sizes come from the same Spark/Parquet keys as Spark's writer
(parquet.block.size, parquet.page.size, parquet.dictionary.page.size), and maxRecordsPerFile rolls files
mid-batch. The row-group boundary is checked adaptively as records accrue (the open group's own rows), so a
small configured row-group size is honoured within a batch (#997).
Nested types. Structs, arrays and maps of the supported leaf types, in the standard layout and — under
spark.sql.parquet.writeLegacyFormat — the legacy bag/array LIST and key_value MAP, with the
repetition/definition levels computed from each row's Arrow lane.
Calendar rebase and INT96. datetimeRebaseModeInWrite and int96RebaseModeInWrite in CORRECTED,
LEGACY or EXCEPTION, written exactly as Spark's ParquetWriteSupport (the LEGACY footer keys included so
Spark's reader rebases back). INT96 is Spark's default timestamp output. Legacy-format decimals are written as
FIXED_LEN_BYTE_ARRAY of the minimal byte length.
Partitioned and bucketed writes. Dynamic partitions (the partition directories, escaping and
__HIVE_DEFAULT_PARTITION__ are Spark's), Hive bucketing (Spark's _NNNNN suffix or Hive's NNNNN_0_ prefix;
the bucket id is pmod(murmur3hash(keys, 42), n), or Hive's HiveHasher for a Hive-compatible write), and
concurrent output writers (spark.sql.maxConcurrentOutputFileWriters).
Configuration
Only the keys a user would set; defaults verified in
spark/src/main/scala/io/vecruntime/spark/VectorConf.scala.
| Key | Default | Type / values | What it does |
|---|---|---|---|
spark.vecruntime.scan.nativeParquet.enabled |
true (off when Comet's scan is active) |
boolean | The native Parquet reader. false uses Spark's vectorized reader under our operators. |
spark.vecruntime.scan.nativeParquet.dictionaryStrings |
true |
boolean | Emit a string column's dictionary-encoded batches as dictionary ids over the row group's dictionary, not resolved bytes. |
spark.vecruntime.scan.nativeParquet.prefetchFiles |
12 |
int, 0–16 | Files of the split opened ahead on background threads. 0 opens each on the task thread. |
spark.vecruntime.scan.nativeParquet.prefetchRowGroups |
4 |
int, 0–16 | Row groups of the current file read ahead. 0 reads each on the task thread. |
spark.vecruntime.scan.nativeParquet.prefetchMaxBytes |
256m |
bytes | Cap on the compressed bytes one task's read-ahead holds. 0 or less: no cap. |
spark.vecruntime.scan.nativeParquet.prefetchExecutorMaxBytes |
2g |
bytes | The same cap across every task of the executor. 0 or less: no cap. |
spark.vecruntime.scan.nativeParquet.decodeAhead |
0 |
int, 0–16 | Batches decoded ahead of the task thread on a producer thread. 0 decodes on the task thread. |
spark.vecruntime.scan.nativeParquet.decodeAhead.threads |
virtual |
virtual / platform |
The decode-ahead producer's thread kind. |
spark.vecruntime.scan.nativeParquet.parallelDecode |
0 |
int, 0–8 | Helper threads decoding later row groups in parallel. 0 keeps the serial / single-producer path. Engages only for a file with ≥2 row groups at or above parallelDecode.minRowGroupBytes. |
spark.vecruntime.scan.nativeParquet.parallelDecode.threads |
virtual |
virtual / platform |
The parallel-decode helper threads' kind. |
spark.vecruntime.scan.nativeParquet.parallelDecode.minRowGroupBytes |
32m |
bytes | Per-row-group threshold for parallel decode. 0 engages at any size. |
spark.vecruntime.scan.nativeParquet.lateMaterialization |
false |
boolean | Decode a filter's columns first, the others only at surviving rows. Saves decode CPU, not I/O. |
spark.vecruntime.scan.nativeParquet.lateMaterialization.minScanBytes |
1g |
bytes | The smallest scan a decode filter is handed to. |
spark.vecruntime.scan.nativeParquet.lateMaterialization.twoPass |
false |
boolean | Read a row group twice so the pages of other columns holding no surviving row are not fetched. Needs the file's offset index. |
spark.vecruntime.scan.nativeParquet.lateMaterialization.twoPass.maxFraction |
0.5 |
double | Above this surviving-row fraction, the row group is read whole. |
spark.vecruntime.scan.nativeParquet.smallFileThreshold |
0 (off) |
bytes | A file at or under this is fetched whole in one GET before the footer (#834). e.g. 8m. |
spark.vecruntime.scan.nativeParquet.rangeMergeGap |
-1 (off) |
bytes | Coalesce planned byte ranges whose gap is at or under this into one GET (#834). 0 merges only adjacent. |
spark.vecruntime.scan.nativeParquet.rangeTargetSize |
8m |
bytes | Split a coalesced run longer than this into parallel GETs. 0 never splits. |
spark.vecruntime.write.columnar.enabled |
true |
boolean | The columnar Parquet writer (#982). false uses Spark's writer. |
parquet.filter.columnindex.enabled |
false |
boolean | Parquet-java's own key: enable column-index page skipping in the native reader (#873). Set as a session key or spark.hadoop.parquet.filter.columnindex.enabled. |
The read path also honours the standard Spark/Parquet keys — rebase modes
(spark.sql.parquet.datetimeRebaseModeInRead, ...int96RebaseModeInRead), field ids
(spark.sql.parquet.fieldId.read.enabled) — and the write path honours parquet.writer.version,
parquet.block.size, parquet.page.size, parquet.dictionary.page.size, parquet.bloom.filter.*,
spark.sql.parquet.writeLegacyFormat, the write rebase modes, and spark.sql.maxConcurrentOutputFileWriters.
See Configuration reference for the full list.
Not implemented / falls back to Spark
Every case here returns Spark's own result — Spark's reader decodes the one file, Spark's scan stays in the plan, Spark's writer handles the write, or the query fails with Spark's error. None is a wrong answer.
Reader — Spark's reader for the one file:
- A column whose stored encoding or physical type has no native path (e.g.
BYTE_STREAM_SPLITon a type without a lane, a decimal whose stored physical type or scale does not match): the whole file reads with Spark's reader. - A shredded variant below the top level (inside a struct, list or map).
- Encrypted Parquet (modular encryption): decryption is parquet-java's; without the keys the read fails as Spark's does.
- A few reader-defined corners that are Spark's own outcome: a UTC-adjusted timestamp read as
timestamp_ntz, INT32TIME(MILLIS), a missingREQUIREDcolumn, some field-id corner cases, and the two legacy list/variant shapes (list_of_lists_legacy_two_level_test,repeated_variant_group_test).
Reader — Spark's scan for the whole plan:
- A variant extraction pushed below the top level (
spark.sql.variant.pushVariantIntoScan). - Geospatial types (
geometry,geography, Spark 4.2): no decode path; the file's other columns are native. - Any type Spark 4.1 cannot read itself (
FLOAT16, the legacyINTERVALconverted type, 12-byte timestamps): Spark refuses the file before any scan runs.
Reader — carried but not computed on (operators above the scan): variant has no engine lane by design;
time and the ANSI intervals ride their INT64/INT32 lanes for carrying, comparison, grouping, sorting, joins,
windows and count/min/max/first/last, but arithmetic, casts, functions, sum and avg over them stay Spark's.
Writer — Spark's writer for the write (VectorWritePlanner.reason):
- Encryption (
parquet.crypto.factory.class,parquet.encryption.kms.client.class). - Parquet v1 (
parquet.writer.version=v1). - A non-Parquet format (ORC, JSON, CSV, text), or a child operator that is not columnar (unless
write.columnar.fromRowsis on). - A column type not yet supported: interval, variant, and an empty struct — anything outside boolean,
byte, short, int, long, float, double, decimal, date, timestamp,
timestamp_ntz, string, binary, and structs/arrays/maps of those (#842). - A per-column
parquet.*#<column>option other thanparquet.enable.dictionary#and the readparquet.bloom.filter.*#options. - Concurrent output writers whose past-the-limit sort keys do not compile on our lanes.
Not implemented on either side: geospatial types on write, shredded variant writes, Parquet v1 data pages on write, and encryption throughout. These are tracked in the writer work list (#842) and the reader work list; the two detailed pages carry the current state.