Skip to main content
This feature requires StarTree release 0.15.0 or later, and must be enabled on demand — contact StarTree support to activate it.
Best practices for running External Tables, followed by the full reference for every configuration and query option: the table-level sync config, the S3 tier and caching properties, the server/cluster tuning knobs, and the per-query SET options.
Two 0.16.0 features have their own dedicated config reference: Large External Tables (segmentGroupConfig and the cluster-level grouping gate) and Deletion Vectors (enableDeletionVectors and the readiness/pruning config below).

Best practices

Onboarding
  • Enter the bucket name only (no s3://) and set the AWS region in the connection config — a region in config means you don’t depend on the AWS_REGION environment variable.
  • Grant the cluster access to the source bucket with an assumed IAM role (roleArn + optional externalId) or the cluster’s node role; verify with aws s3 ls s3://<bucket>/<prefix>/ before onboarding.
  • For very large sources, start with a smaller prefix subset and scale the cluster before onboarding the full dataset.
  • Set continueOnFileError=true if a sync run should skip a file that fails to process — unreadable, corrupt, or any other per-file failure, not just unreadable ones — instead of failing the whole run (default is to fail). Because it skips on any failure, it can silently drop more data than “unreadable file” suggests, so review sync logs after enabling it. This only affects files during a sync — it does not protect an already-ingested segment whose file is later deleted or compacted away (see the note below).
  • If your source compacts or rewrites files, onboard it through an Iceberg REST catalog, not the raw S3/GCS Data Lake source — a REST catalog reconciles compaction via snapshot metadata, while raw sources pin segments to physical file paths that break when files are rewritten. See Segments fail to load after upstream compaction.
Schema
  • Keep the data types the preview step infers; overriding them can break segment generation.
  • Set a time column where the data has one — it enables time-based segment pruning and retention. Recommended but not mandatory; tables without a time column still work.
Indexes
  • Add indexes for the columns you filter and group by — without them, queries scan remote data.
  • For dictionary-backed indexes (inverted, FST, IFST), keep the forward index RAW and add an explicit dictionary block.
  • Avoid grouping by derived/computed columns — it defeats segment pruning.
Tiered storage — data & index caching
  • Enable the page cache (enable.prefetch.page.cache, a per-table tier setting) and ask your cluster operator to turn on preload.enable — a server-level setting that cannot be set per table — so index data is served locally. See Caching and index pinning.
  • Pin small, hot indexes (bloom filter, dictionary) with preload.index.keys.override; let the index cache serve the larger/colder ones.
  • Turn on index consolidation to collapse a segment’s per-column index files into one mmap, avoiding the OS max_map_count limit on wide tables — see Caching and index pinning.
  • Enable pre-warm (pinot.parquet.prewarm.enabled) to pay the cache-fill cost at segment load instead of on the first query.
Large-scan queries (OOM protection)
  • For tables that get heavy scans or aggregations, enable the query OOM killer so a single runaway query is killed instead of crashing the server — see Query OOM protection.
  • Optionally enable the pre-kill pause so the server briefly pauses to let GC reclaim memory before killing — this can save a query that would otherwise be killed.
Operations

Caching and index pinning

External Table queries read indexes from remote storage, so keeping index data local is what makes them fast. There are two ways to do that — pinning and the index cache — and they’re used together. Pin the small, always-needed indexes; let the cache handle the rest. Pinning is resident memory, so reserve it for compact indexes you hit on most queries — bloom filters, dictionaries, and small range/inverted indexes. Everything larger or colder (JSON, text, wide columns) should go through the index cache, which fetches on demand and evicts what isn’t used.
preload.enable is a server / cluster setting, not a table config key — it cannot be set in tierBackendProperties. Putting it there is silently accepted and does nothing; even StarTree’s own onboarding flow currently writes it into new table configs, where it has no effect. Ask your cluster operator to turn it on server-side. Until it’s on, listing keys in preload.index.keys.override pins nothing.
Pin keys are <column>.<indexKey> (or * for all columns), where indexKey is the index’s file key — e.g. inverted_index, range_index, bloom_filter, or dictionary. Examples: payment_type.inverted_index, *.bloom_filter, *.dictionary. Index consolidation. On a wide table, each column’s index is a separate memory-mapped file, and many segments × many columns can exhaust the OS memory-map limit (max_map_count) — surfacing as native-memory / mmap allocation failures and server restarts. Set preload.enable.index.consolidation=true to pack a segment’s index regions into a single file per segment, drastically cutting the mmap count. Onboarding enables this by default.

Where configs live

Server and cluster keys resolve in the order cluster config (Helix) → JVM -D property → built-in default. Prefer cluster config so values can change without a restart. Exception: in current releases the pinot.parquet.page.cache.* and pinot.parquet.pagereader.* keys (and pinot.server.index.inverted.enable.startree.reader) are cluster-config-only — their JVM -D fallbacks were removed.

Table config — ExternalTableSyncTask

Catalog & mode

External Tables read Parquet files only. There is no inputFormat config — the sync engine doesn’t read one, so setting any such key has no effect.
glue is also accepted as a catalogType value when a table is created, but sync then fails at runtime — it is not a supported catalog type. Onboard AWS Glue-backed sources with catalogType=iceberg-rest and the Glue service type under catalog.iceberg-rest.* instead; see AWS Glue: Onboarding via API.

Connection keys

The catalog connection keys are prefixed catalog.s3.* or catalog.iceberg-rest.* (REST URI, service type — glue, s3Tables, unity, or rest (Nessie and other generic Iceberg REST catalogs) — warehouse/prefix, namespace/table, and auth.rest.* / auth.storage.* credentials). The full list with examples is in AWS Glue: Onboarding via API, Unity Catalog: Onboarding via API for Unity-specific auth, or Nessie: Onboarding via API for the generic rest adapter.

Scheduling & task sizing

Don’t use catalog.s3.prefix as a way to bound where ingestion starts within a large bucket — prefix is a permanent filter on which objects are ever considered, not a one-time starting cursor. Narrowing it to “skip ahead” permanently excludes everything outside that range, including future files, once the checkpoint reaches the end of the prefix. Use catalog.s3.startAfter instead, which only affects the first run.
continueOnFileError vs. continueOnError — two different configs. continueOnFileError (this ExternalTableSyncTask config) skips an entire file that fails to process, for any reason — not just unreadable ones — so one bad file doesn’t fail the run. continueOnError (an OSS field in the table’s ingestionConfig, not here) skips individual bad rows while reading a file or stream. Both apply only during ingestion — neither one protects a segment that is already ingested. In particular, if a source file is deleted or compacted away upstream after its segment exists, the server fails to serve that segment regardless of either flag; the durable fix for compacting sources is an Iceberg REST catalog.

Snapshot processing (Iceberg)

Checkpointing & schema evolution

How schema evolution resolves changes (0.16.0+). When schemaEvolution.enabled=true on a catalogType=iceberg-rest source, each sync unions the current schema against the source’s Iceberg field-id/alias history instead of matching by column name alone. This means a source column rename is tracked as an alias rather than dropping and re-adding a column — the existing Pinot column name is preserved. Two limitations: resolution only covers top-level fields (nested-field renames aren’t resolved), and a detected type widening is rejected rather than silently applied — set schemaEvolution.failFast=true if you want such a rejection to abort the run instead of being logged and skipped. When fail-fast aborts a run, the status API reports failurePhase: SCHEMA_UPDATE.
Nested logical names under column mapping (storeNestedSchema.enabled). Delta UniForm tables created with delta.columnMapping.mode (name or id) don’t store your column names in the Parquet files — each physical column is an opaque col-<uuid>, and the logical name plus a stable field-id live only in table metadata. Top-level column names are always resolved back to logical names, with or without this flag. Names inside a complex value are only resolved when storeNestedSchema.enabled is on (the default) for the table’s ExternalTableSyncTask config; set it to false to opt out, and nested keys are then stored under their physical names, so JSON_EXTRACT_SCALAR(customer, '$.name', ...) returns NULL even though the data is there. Only an explicit, case-insensitive false opts out — an unset, blank, or unrecognized value keeps the default on.The flag applies to every schema-writing path (onboarding, preview, and the schema-evolution union during sync) and is independent of schemaEvolution.enabled. It defaults to on because leaving it off silently materializes the wrong JSON keys for complex columns on column-mapped tables; the only cost of stamping is a larger ZK-persisted schema on tables that don’t need it, and it does nothing for native Iceberg or add_files tables where physical names already equal logical names. Changing it on an already-onboarded table takes effect only on the next schema write (re-preview/re-register, or the next sync if schema evolution is on), followed by a segment reload — resolution happens at read time, so segments don’t need regenerating. A table onboarded before this default flipped gets that one-time schema update and segment reload automatically on its first sync after upgrade, if it has complex columns and no explicit storeNestedSchema.enabled setting. See Unity Catalog — schema options for column mapping.

Tier backend properties (caching & preload)

Set on the S3 tier in tierConfigs[].tierBackendProperties. The onboarding/preview flow sets sensible defaults.
Two-tier scaffold for skipSegmentPreprocess=true (create-time only). If the table config already sets indexingConfig.skipSegmentPreprocess=true when it’s created — through the preview API or CREATE TABLE ... WITH (type='iceberg', ...) — the enrichment scaffolds two tiers instead of the usual single fixed-selector tier: <tableName>_preprocessed_tier (segmentAge=2h) and <tableName>_s3_tier (segmentAge=0s), both keyed on creationTime. They share the same bucket and region but get distinct pathPrefix values (the preprocessed tier appends /preprocessed), so a segment aging from one tier to the other is a real physical move, and matching indexingConfig.tierOverwrites entries are written so segment preprocessing runs on the preprocessed tier and stays skipped on the s3 tier. New segments land on the s3 tier and relocate to the preprocessed tier once they pass the 2-hour threshold. This applies only at create time: adding skipSegmentPreprocess=true to an existing table via PUT /tables/<name>/config does not scaffold the second tier, and turning the flag back off does not remove one already created.

Common

¹ The onboarding/preview flow sets these to true on newly scaffolded tables (preload.enable.index.consolidation, enable.prefetch.page.cache). Existing tables are never re-enriched — if a table predates these defaults, add the keys manually. ² pathPrefix is not validated. Nothing errors if it’s missing or wrong — files simply land at the bucket root instead of under a per-table prefix. Always set it explicitly so each table’s files get their own folder.
preload.enable, preload.dir, preload.total.size, and preload.load.existing.buffers are not tier/table settings, even though they sound like they belong here next to preload.enable.index.consolidation. They are server / cluster settings — see Preload (server-level) below.

Preload tuning

The S3 tier also exposes advanced on-demand buffer, mmap, and read-ahead sub-properties (ondemand.*, mmap.*, readAhead.*). These are internal tuning knobs — leave them at defaults unless StarTree support advises otherwise.

Server / cluster configs

Parquet page cache sizing (in-memory)

These in-memory pools are a separate concern from the persistent disk cache below — they don’t share a namespace or a budget with it.

Pre-warm at segment load

Page reader & prefetch

Prefetch depth is bounded by the query option prefetch.projection.queue.size (default 10) — how many projection blocks are fetched ahead when the scan queue refills. The other prefetch-related knobs are the prefetch buffer size (...prefetch.size.mb), the look-ahead in-flight cap (...segment.lookahead.max.inflight.chunks), the in-flight chunk-read cap (...max.in.flight.chunk.reads), and the page-reader pool size (...pagereader.pool.size).

Parquet read modes (PAGE / CHUNK)

Explicit physical-read mode for Parquet V6 query prefetch, letting a per-page range-read strategy be compared against a whole-column-chunk strategy without an adaptive policy choosing between them.
These are cluster-config only, not query options. PAGE remains the recommended default — StarTree’s own benchmarks found no universal crossover point, and CHUNK can transfer significantly more bytes than needed on selective queries. Canary CHUNK only for scan-heavy, request-constrained workloads, and monitor the per-mode GET count, physical/useful bytes, and amplification metrics.

Filter-ahead prefetch

Default-off stage that evaluates the query filter ahead of scanners and warms projection pages for matched rows on external Parquet scans. It is best effort — query execution always performs the authoritative filter regardless of prefetch outcome.

Persistent disk cache (pinot.server.instance.disk.cache.*)

Breaking change (0.16.x). Every persistent disk cache for External Tables and tiered storage — Parquet page cache, segment metadata cache, preload, mmap, and the deletion-vector Puffin cache — now shares one config namespace, pinot.server.instance.disk.cache.*, and one root directory. The old pinot.parquet.page.cache.disk.*, pinot.server.instance.segment.cache.*, and pinot.server.instance.buffer.reference.manager.* (preload/mmap) keys, and the table-level iceberg.dv.fetch.mode / iceberg.dv.puffinCache.* keys (see Deletion Vectors), are retired — not read, not aliased. Migrate before upgrading. All settings below take effect at server startup only; changing or deleting one requires a restart.
The table below omits the shared pinot.server.instance.disk.cache. prefix. Sizes accept bytes or binary units (K/M/G/T/P, optionally followed by B); the page-capacity and fragment-size keys also accept a percentage of filesystem capacity, and 0 disables that pool or layer. Fixed cache locations under <root> (the resolved directory): External Table Parquet pages at remote_data_cache, tiered-storage index pages at index_cache, segment metadata/index headers/Parquet page indexes at tieredStorage/segmentCache, preloaded indexes at tieredStorage/preload, LFU mmap indexes at tieredStorage/mmap, and Puffin files at dv-puffin-cache/<tableNameWithType>/<scheme>. Disk caching and restart reuse (files survive a graceful shutdown and are reused on the next start) are both on by default; to force a cold rebuild of one cache, stop the server and remove only that cache’s directory, then restart — don’t remove the whole data directory.

Index reader

Query OOM protection (large scans)

A query that scans a large amount of remote data can grow the server heap until the process OOMs. Server-side per-query memory accounting protects against this: when heap usage crosses a threshold, the most memory-hungry query is killed (or briefly paused first) instead of the whole server crashing. Recommended for tables that get heavy scans or aggregations.
On StarTree Cloud, pinot.query.scheduler.accounting.enable.thread.memory.sampling and pinot.query.scheduler.accounting.oom.enable.killing.query are already turned on by default for servers — your cluster is protected without any action. The steps and settings below matter only for self-managed clusters that haven’t enabled them.
To protect a cluster running large External Table scans, set (full keys, all prefixed pinot.query.scheduler.accounting.): ...enable.thread.memory.sampling=true and ...oom.enable.killing.query=true. Add the pre-kill pause (...oom.panic.allow.pre.query.kill.pause=true with ...oom.pre.query.kill.pause.duration.ms=2000) so transient spikes recover via GC instead of killing the query.

Query options

Set per query with SET "key" = 'value'. Several of these are also tier or server configs — setting them as a query option overrides the config for that one query. Example — bypass the cache for one query:

Time travel (query-time snapshot pinning)

By default, a query against an Iceberg external table reads the newest fully-synced snapshot, resolved per query at the broker. The snapshotVersionByTable query option pins the query to a specific snapshot instead — the broker routes it to exactly that snapshot’s file set, so you get the data (and row-level deletes) as of that snapshot:
The value is a JSON map of table name → snapshot ID, so a single query can pin several tables (one entry per table). Table names are canonicalized — nyc_taxi_trips, nyc_taxi_trips_OFFLINE, and mydb.nyc_taxi_trips all resolve to the same entry. The option works on both the single-stage and multi-stage query engines.

Caveats

  • Snapshot IDs only, no SQL clause. There is no FOR VERSION AS OF / FOR TIMESTAMP AS OF syntax and no timestamp-based variant. Pass the numeric Iceberg snapshot ID (as a JSON number or numeric string) via the query option.
  • Bounded time-travel window. Only snapshots still in the table’s active snapshot list can be pinned. Snapshot retention keeps the newest iceberg.snapshotProcessing.retention.maxActiveVersions versions (default 2). A configured value below 2 is ignored outright and the default of 2 — also the floor — is used instead. This is not arbitrary Iceberg-history time travel — it is “pick one of the last N snapshots StarTree has synced and still retains.”
  • Iceberg catalog sources only. Supported for Iceberg REST catalog sources (AWS Glue, S3 Tables, Unity Catalog, generic REST). On raw S3/GCS Data Lake tables the option is silently ignored — file listings have no snapshot concept.
  • Expired or unknown snapshots fail the query. Pinning a snapshot that is not (or no longer) active returns an error (Iceberg snapshot <id> is not active for table <table>) rather than silently falling back to the latest snapshot. Malformed JSON in the option also fails the query.
  • No pin = latest ready snapshot. Without the option, each query reads the newest snapshot that is fully synced and cache-ready at that moment. Two queries issued at different times may therefore read different snapshots; pin a snapshot when you need repeatable reads across queries.
  • On some tables the broker sets it automatically. Every query on a deletion-vector-enabled table runs against a pinned snapshot automatically — and so does every query on a table with snapshot consistency enabled, or one using segment groups. You only set the option yourself to override the default (latest) choice.
  • Not the same as initialSnapshotId. The table-config keys iceberg.snapshotProcessing.initialVersionSelector / initialSnapshotId (and the DDL snapshot_mode / snapshot_id) choose where ingestion starts when the table is created. They do not affect which snapshot a query reads.
  • Finding snapshot IDs. GET …/externalTable/status?includeLag=true returns the newest synced snapshot (lag.synced.snapshotId) and the upstream head (lag.upstream.snapshotId) — see Observability. For older retained snapshots, get IDs from the source catalog (e.g. the Iceberg table’s snapshots metadata); there is currently no API that lists all active snapshot IDs.