External query engines¶
Use an external engine to query Siglake's Apache Iceberg tables directly. The warehouse stores Parquet files, so a compatible Iceberg reader does not need a running Siglake process.
These queries read Siglake's primary storage. They do not use an export copy.
Choose when to use an external engine¶
- If you stop running Siglake, your data remains as Parquet in your bucket, described by an Iceberg catalog.
- Use Siglake for interactive log search and freshness; use Trino for a monthly join against your warehouse.
- Direct reads need no extract, transform, and load (ETL) copy or export job.
Gather the connection details¶
| Thing | Value |
|---|---|
| Catalog type | Iceberg SQL catalog (JDBC) |
| Catalog URI | Your Postgres address, the same SIGLAKE_CATALOG_URI |
| Warehouse | Your S3 URL, the same SIGLAKE_WAREHOUSE_URL |
| Namespace | siglake by default; tenant_<id> for other tenants |
| Credentials | Read access to the bucket |
Grant external readers read-only access. Siglake owns the write path; concurrent writers from another engine would fight with compaction over the same manifests.
Siglake tables use Iceberg format version 2. The events event time has two
required columns so that v2 readers remain compatible without losing the source
precision. Their declared Iceberg types are in the generated
table schema reference; this is how they land
in Parquet:
| Column | Parquet type | Meaning |
|---|---|---|
timestamp |
required INT64 TIMESTAMP(MICROS, isAdjustedToUTC=true) |
Event time, floored to a microsecond; the partition and first sort column |
timestamp_ns |
required INT64 with no logical type | The OTLP time_unix_nano value verbatim; the second sort column |
This contract replaced an earlier one, in which timestamp was a v3-only
timestamptz_ns and the tables were format version 3. Warehouses written
before the change are still format version 3 and must be recreated, not
migrated. The measurements that motivated the change are kept in
Superseded results.
Reconstruct nanosecond timestamps from timestamp_ns¶
Reconstruct an exact nanosecond timestamp by converting timestamp_ns with the
engine's own function. timestamp is the portable time column, floored to a
microsecond; timestamp_ns preserves all nine fractional digits and
distinguishes the rows that share one microsecond. In Siglake's DataFusion SQL,
to_timestamp_nanos(timestamp_ns) does the conversion; external engines use
their corresponding one (from_unixtime_nanos in Trino and make_timestamp_ns
in DuckDB), or retain the bigint where the engine's timestamp type is only
microsecond precision.
Query from DuckDB, Trino, or Spark¶
Each engine reads the committed Iceberg snapshot. Configure it with the same
catalog, warehouse, and namespace from the table above. Then query events
through the engine's Iceberg connector:
-- DuckDB, using the current Iceberg metadata file
SELECT count(*)
FROM iceberg_scan('s3://<bucket>/warehouse/siglake/events/metadata/<current>.metadata.json');
-- Trino
SELECT count(*) FROM siglake.siglake.events;
After configuring spark.sql.catalog.siglake as an Iceberg catalog, run:
DuckDB needs an explicit metadata path unless you put an Iceberg REST catalog in front of Siglake's JDBC catalog. The Trino and Spark sections below give their catalog configuration. External readers lose the WAL buffer, Siglake metadata fast paths, raw-text indexes, ordered early-stop, and Siglake SQL user-defined functions (UDFs). They retain Iceberg snapshot isolation and standard partition and statistics pruning.
Reader compatibility¶
Measured on 2026-09-06 against a fresh fixture written by Siglake merge commit
f0bacb5: a format-version-2 events table, 2,000 rows one nanosecond apart in
two data files, one day(timestamp) partition, a SQLite Iceberg JDBC catalog
and a local-filesystem warehouse. Method and full output are in
the evidence section below.
| Engine | Version tested | Iceberg library | Reads events |
|---|---|---|---|
| Trino | 483 (2026-07-18) | 1.11.0 (bundled in trino-iceberg-483) |
Yes: 2,000 rows; timestamp(6) with time zone + bigint |
| Spark | 3.5.9 + iceberg-spark-runtime-3.5_2.12:1.11.0 |
1.11.0 | Yes: 2,000 rows; timestamp + bigint |
| DuckDB | v1.5.5 + iceberg extension 45163a28 (core) |
Not applicable | Yes: 2,000 rows; TIMESTAMP WITH TIME ZONE + BIGINT |
| PyIceberg | 0.12.0 (PyArrow 25.0.1) | 0.12.0 | Yes: 2,000 rows; timestamptz + long |
Combinations not in this table are unverified. We have not run them, and this page does not claim they work. In particular, Spark 4.x and DuckDB extension builds other than the core hash above are untested.
Every result on this page is labelled with the timestamp contract and the Siglake revision it was measured against. Results for the superseded format-version-3 contract, where three of these four engines failed, are kept as dated history in Superseded results.
DuckDB¶
DuckDB's iceberg_scan reads the current events table from its explicit
metadata path. Checked on 2026-09-06 against the fresh format-version-2 fixture
written by Siglake f0bacb5:
| DuckDB | iceberg extension |
Contract | Reads events |
|---|---|---|---|
| v1.5.5 (latest release, 2026-07-22) | 45163a28 (core) |
v2, timestamptz + long (Siglake f0bacb5) |
Yes |
DuckDB reports timestamp as TIMESTAMP WITH TIME ZONE and timestamp_ns as
BIGINT. make_timestamp_ns(timestamp_ns) reconstructs all nine fractional
digits when a timestamp value is needed.
That same extension build could not read Siglake tables at all under the superseded v3 contract; see Superseded results.
Read events from DuckDB¶
-
Install and load the Iceberg extension:
-
For a warehouse on S3, add
httpfsand a read-only secret for the bucket: -
Read the table's current metadata path from the catalog database, not from DuckDB:
-
Scan that path:
The count matches what siglake sql "SELECT count(*) FROM events" reports for
the same snapshot. Repeat step 3 after Siglake commits again: the path changes
on every commit, and a stale path reads the older snapshot.
Two catalog caveats explain why step 3 exists. DuckDB's ATTACH … (TYPE
ICEBERG) speaks the Iceberg REST catalog only (including its Glue, S3 Tables
and Polaris flavours), so attaching Siglake's SQL catalog directly is not an
option: a DuckDB reader needs either a REST catalog in front of it or the
explicit metadata path from step 3. The bare directory form of iceberg_scan
also requires a version-hint.text file, which Siglake does not write. Scanning
the Parquet files directly works but loses snapshot isolation.
Trino¶
Verified on 2026-09-06 with Trino 483 against the format-version-2
contract as written by Siglake f0bacb5. Trino reports the portable
timestamp column as
timestamp(6) with time zone and timestamp_ns as bigint. For
nanosecond-exact event time, use the sibling:
The catalog file below shows the production shape: a Postgres catalog and S3 warehouse. What we actually ran was the same connector against a Postgres-shaped SQLite catalog on local disk; the Postgres and S3 legs are untested here.
# etc/catalog/siglake.properties
connector.name=iceberg
iceberg.catalog.type=jdbc
iceberg.jdbc-catalog.driver-class=org.postgresql.Driver
iceberg.jdbc-catalog.connection-url=jdbc:postgresql://catalog.internal:5432/siglake
iceberg.jdbc-catalog.connection-user=reader
iceberg.jdbc-catalog.connection-password=…
iceberg.jdbc-catalog.catalog-name=siglake
iceberg.jdbc-catalog.default-warehouse-dir=s3://my-siglake-warehouse/warehouse
fs.s3.enabled=true
s3.region=us-east-1
Three properties are easy to get wrong:
iceberg.jdbc-catalog.driver-classis required. Trino refuses to start the catalog without it, and the class must be on the Iceberg plugin's classpath.iceberg.jdbc-catalog.catalog-namemust equal thecatalog_namecolumn Siglake wrote into the catalog'siceberg_tablestable. Usesiglakeunless you changed it.fs.s3.enabledis the current name;fs.native-s3.enabledstill works as a deprecated alias and logs a warning.
SELECT sourcetype, count(*)
FROM siglake.siglake.events
WHERE timestamp >= current_timestamp - INTERVAL '1' DAY
GROUP BY sourcetype;
Spark¶
Spark 3.5.9 with Iceberg 1.11.0 reads both current event-time columns.
Measured on 2026-09-06 against the format-version-2 contract as written by
Siglake f0bacb5: DESCRIBE reports timestamp and bigint, and the
fixture's count and exact timestamp_ns bounds agree with Siglake. Spark
timestamps are microsecond precision, so keep timestamp_ns as a bigint in
comparisons that must retain all nine digits.
Under the superseded v3 contract this same Spark and Iceberg pair could not read the table at all; see Superseded results.
This is the configuration shape we tested:
spark = (SparkSession.builder
.config("spark.sql.extensions",
"org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions")
.config("spark.sql.catalog.siglake", "org.apache.iceberg.spark.SparkCatalog")
.config("spark.sql.catalog.siglake.catalog-impl",
"org.apache.iceberg.jdbc.JdbcCatalog")
.config("spark.sql.catalog.siglake.uri",
"jdbc:postgresql://catalog.internal:5432/siglake")
.config("spark.sql.catalog.siglake.warehouse",
"s3://my-siglake-warehouse/warehouse")
.config("spark.sql.catalog.siglake.io-impl",
"org.apache.iceberg.aws.s3.S3FileIO")
.getOrCreate())
spark.sql("SELECT host, count(*) FROM siglake.siglake.events GROUP BY host").show()
The local evidence used the same JdbcCatalog against SQLite and local disk;
the production Postgres and S3 legs remain unverified.
Accelerations external readers lose¶
External readers get the full committed dataset and correct partition and statistics pruning. Iceberg's snapshot isolation means a reader sees a consistent snapshot even while Siglake commits.
Parquet-native per-column blooms are an opt-in, write-only knob:
SIGLAKE_PARQUET_NATIVE_BLOOMS=on. They have been off by default since
2026-08-06 because time-sorted files spread every tag value across every row
group, preventing useful pruning.
External readers do not get these Siglake features:
| Siglake feature | Why not |
|---|---|
| The WAL buffer | Uncommitted rows are only visible through Siglake's query tier. External readers see committed data only. |
| Zero-scan aggregates | Group-count and time-bucket footers are Siglake-specific KV metadata. Other engines scan. |
| Trigram / token bloom pruning | Siglake-specific footer keys. |
| Inverted-index row selection | Puffin sidecars in a Siglake-specific format. |
| Ordered early-stop | Depends on Siglake's ordering-advertisement gate. |
attr_get() / match_*() |
Siglake UDFs. Use your engine's JSON functions on attributes instead. |
A GROUP BY host that Siglake answers in 3 ms with rows_scanned: 0 requires
a scan in Trino or DuckDB. Use those engines for analytical work instead.
The time-ordered physical layout still helps every reader because time-bounded predicates prune at the manifest and row-group level in any engine.
Attributes from other engines¶
attributes is a JSON string. Use your engine's JSON functions:
-- DuckDB (using the current metadata path, per the DuckDB section above)
SELECT json_extract_string(attributes, '$."k8s.namespace"') AS ns, count(*)
FROM iceberg_scan('s3://bucket/warehouse/siglake/events/metadata/00012-<uuid>.metadata.json')
GROUP BY ns;
-- Trino
SELECT json_extract_scalar(attributes, '$["k8s.namespace"]') AS ns,
count(*) FROM siglake.siglake.events GROUP BY ns;
-- Spark SQL
SELECT get_json_object(attributes, '$["k8s.namespace"]') AS ns,
count(*) FROM siglake.siglake.events GROUP BY ns;
Each engine takes the JSON path in its own syntax, and a dotted attribute name has to be quoted inside the path, as above, so the dot is not read as another level.
If an attribute is promoted to a typed column, query that column directly. It works in every reader and avoids JSON extraction.
Operational cautions¶
- Snapshot expiry is on by default and retains the last 100 snapshots. A very
long-running external query could have its snapshot expired underneath it.
Widen
snapshotExpire.retainLastif you run long analytical jobs. - Orphan garbage collection deletes files. Its safety age (
--min-age-secs) guards in-flight writes, but a reader holding a very old snapshot reference is not part of that calculation. - Do not write from an external engine. Compaction rewrites files and swaps manifests atomically, and another writer committing to the same table will produce conflicts at best.
Verifying interoperability¶
A quick sanity check that both engines agree:
The counts should match, modulo rows still in the WAL buffer that Siglake can see and the external reader cannot. Query a closed time window in the past to compare exactly.
To reproduce the compatibility evidence below rather than trust it, run the Siglake repository's own check:
It writes a fixture with siglake iceberg-demo, asserts the Siglake-side
contract, then has each engine read the same warehouse and agree on the row
count, the nanosecond bounds and the decoded microsecond timestamp. It finds
duckdb and spark-sql on the path and PyIceberg through python3, and skips
an engine it cannot find. The script's header documents the option that turns a
skip into a failure, and where Spark looks for the SQLite JDBC driver the
fixture's catalog needs.
Compatibility evidence¶
This is how the table in Reader compatibility was
produced, so you can reproduce it or repeat it against a newer engine. Every
result below was measured on 2026-09-06 against the current contract:
timestamp as a microsecond timestamptz, timestamp_ns as a required
long, with tables at Iceberg format version 2, as written by Siglake f0bacb5.
The fixture used a fresh debug build of Siglake merge commit f0bacb5 on Linux
x86-64:
That writes a SQLite Iceberg JDBC catalog (iceberg_tables /
iceberg_namespace_properties, the standard V1 catalog schema) and a
file:// warehouse. The events table came out at format version 2,
partitioned by day(timestamp): 2,000 deterministic rows one nanosecond apart
from 2026-01-01T00:00:00Z, two data files and one partition. PyArrow inspected
the written Parquet schema as required INT64
TIMESTAMP(MICROS, isAdjustedToUTC=true) for timestamp and required plain
INT64 for timestamp_ns. Siglake's contract check and reference answers were:
| Query | Siglake |
|---|---|
count(*) |
2,000 |
count(*) GROUP BY host |
host-0…host-3, 500 each |
min(timestamp_ns) |
1767225600000000000 |
max(timestamp_ns) |
1767225600000001999 |
count(DISTINCT timestamp) |
2 |
count(DISTINCT timestamp_ns) |
2,000 |
The fixture writer also asserted format version 2, Iceberg timestamptz /
long, the nanosecond round-trip, and the (timestamp, timestamp_ns) total
order before an external engine ran.
Trino 483 used the release server with trino-iceberg-483 (Iceberg 1.11.0) on
Temurin JDK 25.0.4.1+1, single node, catalog as in the Trino section
but pointed at the fixture's SQLite catalog with
iceberg.jdbc-catalog.driver-class=org.sqlite.JDBC. DESCRIBE reported
timestamp(6) with time zone and bigint; the count and timestamp_ns bounds
matched. from_unixtime_nanos(min(timestamp_ns)) returned
2026-01-01 00:00:00.000000000 UTC, and the maximum returned
2026-01-01 00:00:00.000001999 UTC.
Spark 3.5.9 (spark-3.5.9-bin-hadoop3) ran on Temurin JDK 21.0.12.1+1 with
iceberg-spark-runtime-3.5_2.12:1.11.0 and sqlite-jdbc:3.53.4.0, the same
catalog via org.apache.iceberg.jdbc.JdbcCatalog. DESCRIBE reported
timestamp and bigint; the count and timestamp_ns bounds matched. The
microsecond timestamp bounds were 2026-01-01 00:00:00 through
2026-01-01 00:00:00.000001.
DuckDB 1.5.5 with core iceberg extension 45163a28 used
iceberg_scan on the explicit current metadata file. It returned the same
count and exact long bounds and reported timestamp as
TIMESTAMP WITH TIME ZONE. make_timestamp_ns(max(timestamp_ns)) returned
2026-01-01 00:00:00.000001999.
PyIceberg 0.12.0 with PyArrow 25.0.1 loaded the same explicit metadata
file as a static table. Its schema reported required timestamptz and long;
an Arrow scan of timestamp_ns returned the same count and bounds.
The fixture, DuckDB and PyIceberg checks were driven by Siglake's
scripts/check-external-timestamp-contract.sh with required-engine mode
enabled. Its bundled Spark command currently selects a Hadoop catalog that
cannot resolve the SQLite SQL-catalog fixture; for this measurement a launcher
wrapper redirected that command to the fixture's actual JdbcCatalog. The
unwrapped-driver defect is tracked as Siglake task #1539; the reader result
above is from the same files and SQL the driver generated, not a source-code
inference.
Not covered: object storage (the fixture is on local disk, so S3, GCS and Azure paths are exercised by no engine here), Postgres as the catalog backend, concurrent reads during compaction, Spark 4.x, DuckDB REST-catalog attachment, and any Trino version other than 483.
Superseded results (2026-09-06, format version 3)¶
These results do not describe Siglake today. They are measurements against the superseded timestamp contract, kept because they are the reason the contract changed. Nothing here should be used to judge a current warehouse; for that, see Reader compatibility above.
The superseded contract was measured on 2026-09-06 against a fixture written
by Siglake at commit d356796, a release build on Linux x86-64:
The events table used Iceberg format version 3, with a single
event-time column timestamp as a required timestamptz_ns. There was no
timestamp_ns sibling. Siglake stamped each table with the minimum format
version its schema required, so events, query_audit and the user-index
timestamp schemas were all v3; schemas without a nanosecond timestamp stayed at
v2. An external reader therefore needed both v3 metadata support and the
v3-only timestamptz_ns type.
That second requirement failed hard: an engine that could not map
timestamptz_ns could not read the table at all, even when projecting other
columns. It failed while converting the schema, before planning a
scan.
The fixture was a SQLite Iceberg JDBC catalog and a local-filesystem
warehouse: 500 rows, two data files, one day(timestamp) partition. Siglake's
own answers, which the external readers had to match:
| Query | Siglake (d356796) |
|---|---|
count(*) |
500 |
count(*) GROUP BY host |
host-0…host-3, 125 each |
min(timestamp) |
2026-09-06T09:24:43.734101118Z |
max(timestamp) |
2026-09-06T09:24:43.734345827Z |
Reader compatibility under the superseded contract was measured
2026-09-06 against Siglake d356796:
| Engine | Version tested | Iceberg library | Read events |
|---|---|---|---|
| Trino | 483 (2026-07-18) | 1.11.0 (bundled in trino-iceberg-483) |
Yes: exact counts and nanosecond timestamps |
| Spark | 3.5.9 + iceberg-spark-runtime-3.5_2.12:1.11.0 |
1.11.0 | No: Cannot convert unsupported type to Spark: timestamptz_ns |
| DuckDB | v1.5.5 + iceberg extension 45163a28 / 6561bfca |
Not applicable | No: Unrecognized primitive type: timestamptz_ns |
Trino was the only external reader demonstrated end to end under that contract.
Spark 4.0 and 4.1 were untested, but Iceberg 1.11.0 carried the same missing
type mapping in its spark/v4.0 and spark/v4.1 modules, so the same failure
was expected.
Trino 483 used server-core plus the trino-iceberg-483 plugin (Iceberg
1.11.0) on Temurin JDK 25.0.4.1+1, single node, the catalog shape in the
Trino section pointed at the fixture's SQLite catalog with
iceberg.jdbc-catalog.driver-class=org.sqlite.JDBC. All four queries matched
exactly, including both timestamps to the nanosecond; DESCRIBE events
reported timestamp(9) with time zone;
SELECT … WHERE timestamp >= current_timestamp - INTERVAL '1' DAY GROUP BY sourcetype
returned 500; "events$partitions" showed the single {day_ts=2026-09-06}
partition with 500 records; query_audit read as an empty table rather than
erroring.
Spark 3.5.9 (spark-3.5.9-bin-hadoop3) ran on Temurin JDK 21.0.12.1+1 with
iceberg-spark-runtime-3.5_2.12:1.11.0 and sqlite-jdbc:3.53.4.0, the same
catalog via org.apache.iceberg.jdbc.JdbcCatalog. SHOW NAMESPACES and
SHOW TABLES succeeded. SELECT count(*), DESCRIBE TABLE, and the same
statements against query_audit all failed with
UnsupportedOperationException: Cannot convert unsupported type to Spark: timestamptz_ns.
Reading the data files as plain Parquet failed with
AnalysisException: Illegal Parquet type: INT64 (TIMESTAMP(NANOS,true)).
DuckDB v1.5.5 with iceberg extension 45163a28 (core) and 6561bfca
(core nightly). Every route into the table failed while parsing the table
schema. These routes included a catalog attachment, iceberg_scan on the table
directory, and iceberg_scan on an explicit metadata path:
That was a type failure, not a format-version one: the extension understood
timestamp_ns but not the v3 timestamptz_ns. timestamptz_ns support landed
on duckdb-iceberg main on 2026-07-08; neither of those extension builds
contained it.
PyIceberg was not measured against this contract.