VARIANT columns on External Tables are available in StarTree Cloud 0.18.0 and later.
variant column, or a Parquet column with the VARIANT annotation, becomes a Pinot OPEN_STRUCT column on an External Table. Read a key with col['key'] and the whole document with SELECT col. Keys the writer shredded into typed Parquet columns are read from those columns directly; every other key is read from the variant value bytes.
What changes
- Type mapping. Onboarding maps
varianttoOPEN_STRUCT. No key types are declared up front: each file’s footer tells Pinot which keys exist as typed columns. See Data Type Mapping. - Parquet without the annotation. A Parquet group that has a
metadataleaf plus avalueand/ortyped_valuefield is also treated as VARIANT, so files from writers that predate the annotation work too. - Key access.
props['page_type']works inSELECT,WHEREand aggregates. Nested keys use a dotted path:props['device.os']. - Whole document.
SELECT propsreturns one JSON object with every key: shredded, unshredded, nested objects and arrays. - No data is copied. The column stays in Parquet. Pinot reads it in place, the same as any other External Table column.
A shredded key keeps the type of its Parquet column (
INT, LONG, STRING, and so on). An unshredded key, and an array, read as text.
Which keys are shredded is decided per file. Two files of the same table can store the same key differently. Query results are the same either way; only speed and index use differ.
Requirements
- StarTree Cloud 0.18.0 or later.
- An Iceberg table at format version 3, or Parquet files whose column is a VARIANT group.
- The root of each variant value must be an object. A row whose variant is an array, a scalar or a top-level null can’t be read (see Limits).
- Every External Table column stays
RAWencoded, including theOPEN_STRUCTcolumn.
Enable or disable
There is nothing to turn on. When the External Table is created from the catalog (SQL DDL,/tables/preview, or /externalTable/tables/list), the variant column is added to the schema as OPEN_STRUCT, and its field config is generated as RAW.
There is no setting to map a variant column to STRING or JSON instead.
You can change:
- Indexes on keys and on the unshredded remainder. These are off by default. See Indexes.
- Nothing else. Which keys are shredded, and their types, come from the source files.
Example
Source table
An Iceberg v3 table with avariant column, created in Spark:
variant column on a v2 table is refused by the catalog with variant is not supported until v3.
Sample rows, shown as JSON. In this file the writer shredded page_type, latency_ms, device.os, tags and items; session and user_tier stay unshredded.
u-4 has a string in the INT key latency_ms, so that key reads as null on that row. Row u-5 has no latency_ms, so it also reads as null.
Create the External Table
With SQL DDL (POST /sql/ddl):
/tables/preview and create the table from the result.
Resulting Pinot schema
props appears under complexFieldSpecs with no child specs:
props:
Query
Whole document:"device": {"os": "ios"}), not as a device.os key. Arrays read back as arrays, in order.
Filter on a key:
null, or when its value doesn’t fit the shredded column’s type. A missing key reads as null, not as 0 or ''.
An unshredded key that a row doesn’t have reads as the string
'null', the default null value for a STRING column. So props['user_tier'] = 'null' also matches rows without the key, and a real "null" string can’t be told apart from a missing key. Use IS NULL to test for a missing key.enableNullHandling, MIN sees missing values as the INT default.
Distinct count and GROUP BY on a key:
JSON_EXTRACT_SCALAR to read inside it:
- A key no row has matches nothing. It isn’t an error.
- A dotted path through an array (
props['items.promo.code']) matches nothing. Arrays have no per-key columns; useJSON_EXTRACT_SCALAR. JSON_EXTRACT_SCALAR(props, '$.page_type', ...)andprops['page_type']return the same value.
JSON_MATCH(props, ...)fails withwithout json index. Useprops['key']orJSON_EXTRACT_SCALAR.- A JSON index on the
propscolumn itself is rejected. Put it on the unshredded remainder withsparseFieldConfiginstead.
Indexes
Every External Table column isRAW and its data is the Parquet file, so no index exists until you configure one and reload. A reload reads the values back from Parquet and builds the index. No data is rewritten. See Supported Indexes for index support on other columns.
Indexes go under fieldConfigList[].indexes.open_struct on the props field config. There are two places:
valueFieldConfigs: one entry per shredded key, by name (page_type,device.os). KeepencodingTypeasRAWand ask for a dictionary underindexes. Only keys that are shredded in the source file have a column to index.sparseFieldConfig: one config for the unshredded remainder of every row (the column namedprops$__sparse__). Add ajsonindex, or acompositeJsonindex withincludeAllPaths. Its encoding is alwaysRAW. The older flag"sparseJsonIndex": truemeans the same as ajsonindex here.
IS NULL and IS NOT NULL on any key are answered from the key’s null tracking, not by a filter scan.
The JSON index on unshredded keys falls back to a scan (with a correct result) when:
- the predicate value equals the key’s default null value,
- the predicate is negated (
<>,NOT IN) andenableNullHandlingis on, - the key holds an array,
- a
compositeJsonindex wasn’t told to index that path (useincludeAllPaths).
sorted and h3 aren’t supported on External Tables, at the column level or anywhere under open_struct.
Full config example
sparseFieldConfig.indexes value with:
Apply
Update the table config, then reload:Confirm the index is used
Run the filter and checknumEntriesScannedInFilter in the response. 0 means the index answered it:
props['session'] > 's-5' or props['user_tier'] LIKE 'go%', shows a value above 0.
To see the plan for every segment, use verbose mode. The default form shows one segment’s plan only:
Verify
GET $BASE_URL/schemas/events_variantshowspropswith"dataType": "OPEN_STRUCT".SELECT props FROM events_variant LIMIT 1returns a JSON object with both shredded and unshredded keys.SELECT COUNT(*) FROM events_variant WHERE props['page_type'] = 'HOME'returns the count you expect from the source.- After adding an index and reloading, the same query shows
numEntriesScannedInFilter=0.
Limits
- Object roots only. A row whose variant root is an array, a scalar or a top-level null fails the read with an error. It’s never returned as
{}. - No dense-key tuning.
maxDenseKeys,denseKeys,denseKeyMinFillRateandmaxNestedKeyDepthare rejected. The writer’s shredding decides which keys are columns. - Arrays. Arrays never get their own column, at any depth. They read as JSON text, and filters inside them always scan.
- JSON_MATCH. Not supported on the
propscolumn. - No JSON index on the column itself. A
jsonindex is accepted only onsparseFieldConfig. - Range and LIKE on unshredded keys scan. The JSON index serves only
=,<>,INandNOT IN. - First IS NULL on an unshredded key reads every row once per segment. The result is cached for later queries.
- Type drift on a shredded key. Values that don’t fit the typed column read as null.
- Per-file layout. Index use can differ between segments of one table, because each file is shredded on its own.
- Malformed variant bytes. A row with undecodable variant bytes reads as absent for that key, so one bad row doesn’t fail the scan. The first occurrence per column is logged and every occurrence is counted on a server metric.
- Contradictory files. If a file stores a scalar key both in its typed column and in its residual for the same row, the read fails rather than picking one.
Common errors
Validation errors from table create or update are listed together under
External table '<name>' has <N> validation error(s):.
