Querying GeoParquet Across Domains with Trino

When a query spans more than two domains, or the sides sit in different storage technologies, a database-to-database foreign wrapper stops being the right shape. A federation engine reading each domain’s published GeoParquet snapshot handles it — provided the snapshots are partitioned so the engine can prune, and the query is written so the engine’s predicate pushdown reaches the file statistics rather than reading every row group. This guide sets that up, proves pruning from the query plan, and shows the layout decisions that decide whether a cross-domain join scans gigabytes or terabytes. It is the engine-based path described in Federated Query and Cross-Domain Spatial Joins within Federated Ownership & Routing Architecture.

Prerequisites

Requirement Value / Assumption Notes
Tools Trino ≥ 440 with the Hive connector and geospatial functions ST_* functions are built in
Snapshots Each domain publishes a GeoParquet snapshot port The snapshot is the read surface, not the live store
CRS convention Every snapshot written in EPSG:4326 Cross-domain joins declare this CRS
Layout Snapshots partitioned by a spatial key and an observation window Without partitions there is nothing to prune
Access roles A read-only catalog role per consuming domain Read access is per published product
Environment TRINO_URL, CATALOG Exported before running

Step-by-Step Implementation

1. Lay the snapshots out so pruning is possible

Partition pruning happens on directory structure; row-group skipping happens on column statistics. Both need to be designed in, and neither can be added retrospectively without rewriting.

text
# Published snapshot layout. Directory partitions prune whole files; the sort order
# inside each file makes the bbox column statistics tight enough to skip row groups.
s3://mesh-published/hydrology/flood_extents/
  version=v3.1.0-crs:EPSG:4326-res:10m/
    grid=33N/            # spatial partition — a UTM zone or a tile-pyramid subtree
      window=2026-08/    # temporal partition — the provider's reissue cadence
        part-0000.parquet
        part-0001.parquet

Verify the partitions are actually registered rather than merely present on disk:

bash
trino --server "$TRINO_URL" --execute \
  "SELECT * FROM ${CATALOG}.hydrology.\"flood_extents\$partitions\" LIMIT 5"

2. Register the tables and confirm the geometry column is typed

A GeoParquet geometry column arrives as binary unless the connector is told what it is, and an untyped column defeats every spatial predicate.

sql
-- register.sql — one external table per published product.
CREATE TABLE IF NOT EXISTS hive.mesh.flood_extents (
    feature_id   VARCHAR,
    severity     INTEGER,
    geom_wkb     VARBINARY,          -- GeoParquet stores WKB
    bbox_minx    DOUBLE,             -- flattened bbox columns carry the statistics
    bbox_miny    DOUBLE,
    bbox_maxx    DOUBLE,
    bbox_maxy    DOUBLE,
    version      VARCHAR,
    grid         VARCHAR,
    window       VARCHAR
)
WITH (
    external_location = 's3://mesh-published/hydrology/flood_extents/',
    format = 'PARQUET',
    partitioned_by = ARRAY['version', 'grid', 'window']
);

Bytes scanned for one bounded join, by which narrowing stages engagedFour configurations measured in gigabytes scanned, against a 256 gigabyte session ceiling. With both partition pruning and row-group skipping engaged, the join scans about 3 gigabytes. With partitions only, it scans 41. With flattened bbox columns present but rows unsorted, statistics are too loose to skip anything and it scans 180. With the predicate expressed only against the opaque geometry column, no statistics exist to compare against and the whole snapshot is read.partitions + row groups3 GBpartitions only41 GBbbox cols, unsorted rows180 GBstatistics too loosegeometry predicate only320 GBno usable statisticssession ceiling

The flattened bbox columns are what make skipping work. Parquet keeps min/max statistics per row group for scalar columns and nothing usable for an opaque binary geometry, so a predicate expressed against bbox_minx and friends can skip row groups while one expressed only against the geometry cannot.

Verify the statistics exist and are tight:

bash
trino --server "$TRINO_URL" --execute \
  "SHOW STATS FOR hive.mesh.flood_extents" | head -12
# The bbox columns must show distinct-value and range statistics, not nulls.

3. Write the join so both pruning stages engage

sql
-- cross_domain_join.sql — partition pruning, row-group skipping, then exact predicate.
WITH bounds AS (
    SELECT ST_GeometryFromText('POLYGON((-1 50, 1 50, 1 52, -1 52, -1 50))') AS g,
           -1.0 AS minx, 50.0 AS miny, 1.0 AS maxx, 52.0 AS maxy
)
SELECT p.parcel_id, h.severity,
       ST_Area(ST_Intersection(
           ST_GeomFromBinary(p.geom_wkb), ST_GeomFromBinary(h.geom_wkb))) AS overlap
FROM hive.mesh.parcels p
JOIN hive.mesh.flood_extents h
  ON  p.bbox_minx <= h.bbox_maxx AND p.bbox_maxx >= h.bbox_minx
  AND p.bbox_miny <= h.bbox_maxy AND p.bbox_maxy >= h.bbox_miny
CROSS JOIN bounds b
-- Stage 1: partition pruning. Naming the partition columns skips whole directories.
WHERE p.version = 'v1.2.0-crs:EPSG:4326-res:10m'
  AND h.version = 'v3.1.0-crs:EPSG:4326-res:10m'
  AND p.grid = '33N' AND h.grid = '33N'
  AND h.window = '2026-08'
-- Stage 2: row-group skipping on scalar statistics.
  AND p.bbox_maxx >= b.minx AND p.bbox_minx <= b.maxx
  AND p.bbox_maxy >= b.miny AND p.bbox_miny <= b.maxy
  AND h.bbox_maxx >= b.minx AND h.bbox_minx <= b.maxx
  AND h.bbox_maxy >= b.miny AND h.bbox_miny <= b.maxy
-- Stage 3: the exact predicate, on what survived. Expensive, and now cheap to run.
  AND ST_Intersects(ST_GeomFromBinary(p.geom_wkb), b.g)
  AND ST_Intersects(ST_GeomFromBinary(h.geom_wkb), b.g)
  AND ST_Intersects(ST_GeomFromBinary(p.geom_wkb), ST_GeomFromBinary(h.geom_wkb));

Three stages of narrowing, and what each one needs to have been designed inThree stages read bottom to top. Directory partitioning by version, spatial grid and observation window skips whole files and needs the layout decided at write time. Row-group skipping on flattened bounding-box columns needs those scalar columns to exist and the rows to be sorted within each file. The exact geometry predicate then runs on what survived. The annotations record what each stage cannot be given retrospectively: partitioning and sort order are writer decisions, and retrofitting either means rewriting the snapshot.Exact geometry predicateST_Intersects on survivorsquery-timeRow-group skippingflattened bbox statisticsneeds sort orderPartition pruningversion · grid · windowneeds layout

Verify that both pruning stages engaged, by reading the bytes scanned rather than the wall time:

bash
trino --server "$TRINO_URL" --execute "EXPLAIN ANALYZE $(cat cross_domain_join.sql)" \
  | grep -E 'Input:|physicalInputBytes'
# Compare against the total snapshot size. Scanning a large fraction of it means
# the partition predicates did not match the actual partition column names.

4. Bound the query before it runs

An engine will happily attempt a join no amount of parallelism can finish. Session limits turn that into a fast, explicit refusal.

sql
-- Session bounds, set per consuming role rather than per query, so a consumer
-- cannot opt out of them by omitting a clause.
SET SESSION query_max_scan_physical_bytes = '256GB';
SET SESSION query_max_execution_time      = '30m';
SET SESSION query_max_memory              = '64GB';
SET SESSION join_distribution_type        = 'AUTOMATIC';
-- Broadcast the smaller side rather than repartitioning both, where it fits.
SET SESSION join_max_broadcast_table_size = '2GB';

Configuration Reference

Setting Scope Value Effect
partitioned_by Table version, grid, window The only mechanism that skips whole files
Flattened bbox columns Schema Four DOUBLE columns Enables row-group skipping; geometry alone cannot
Sort order within file Write time By spatial key Tightens the statistics; loose ones skip nothing
Row group size Write time 64–128 MB Larger groups skip more coarsely
query_max_scan_physical_bytes Session 256GB Refuses a join that never had a bounded cost
query_max_execution_time Session 30m Frees the cluster from a runaway query
join_max_broadcast_table_size Session 2GB Broadcast beats repartition for a small side

Where an engine fits and where a foreign wrapper doesFive properties compared between a Trino-style federation engine reading published snapshots and a PostGIS foreign data wrapper joining live stores. The engine spans many domains and heterogeneous storage, parallelises across a cluster, and reads as of the last snapshot. The wrapper spans two PostGIS stores, reads live data with low latency, and needs no extra infrastructure. Most estates run both, with anything a consumer expects to be current on the wrapper and anything analytical on the engine.Federation enginePostGIS FDWDomains spannedmanytwoStorage kindsheterogeneousPostGIS onlyData freshnesslast snapshotliveExtra infrastructurea clusternoneRight foranalytical breadthtight, frequent joins

Common Failure Modes & Fixes

The query scans the entire snapshot despite a bbox predicate. Root cause: the predicate is expressed only against the geometry column, which has no usable Parquet statistics. Fix: add the flattened bbox columns and predicate on them; the geometry predicate stays for exactness, not for skipping.

Partition pruning does not engage. Root cause: the predicate uses a computed value or a join-derived value for a partition column, which the engine cannot evaluate at planning time. Fix: supply partition values as literals; resolve them in the client before submitting.

Row-group skipping is ineffective even with bbox columns. Root cause: rows are unsorted within each file, so every row group’s bbox range spans the whole partition and none can be skipped. Fix: sort by a spatial key at write time; this is a writer change, not a query change.

The join succeeds and returns nothing. Root cause: the two snapshots are in different CRS. The bbox comparisons operate on numbers that do not overlap and the engine reports an empty result rather than an error. Fix: assert the CRS on both sides from the catalog before submitting.

Memory exhaustion on a join that used to work. Root cause: one side grew past the broadcast threshold, so the engine switched to repartitioning both sides. Fix: expected behaviour — either tighten the extent so the small side stays small, or accept the repartition and raise the memory bound deliberately.

FAQ

Why store flattened bbox columns when the geometry already has one?

Because Parquet’s column statistics only work on scalar types. A geometry column is stored as opaque binary, so its row-group statistics carry nothing an engine can compare a predicate against — meaning every row group must be read and decoded before anything can be filtered. Four DOUBLE columns carrying the feature’s envelope give the engine exactly what it needs to skip a row group without decoding a single geometry. The redundancy costs a few bytes per feature and routinely reduces bytes scanned by an order of magnitude.

Should domains publish snapshots specifically for federation?

They should publish a snapshot port, and federation should read it — but the snapshot is a general-purpose analytical port, not a federation-specific artifact. Building a separate copy for the query engine creates a second thing that can disagree with the first, and consumers eventually discover the two answers differ. The requirement federation adds is on layout rather than content: partition by a spatial key and a window, sort within files, and carry the flattened bbox. Those help every analytical consumer, not only the federation engine.

How does this compare with a foreign data wrapper?

They suit different shapes. A wrapper joins two live PostGIS stores with low latency and modest setup, and it is the right tool for a frequent, tight, two-domain join. An engine reads published snapshots across many domains and many storage technologies, parallelises across a cluster, and handles analytical breadth — at the cost of reading data as of the last snapshot rather than live. Most estates end up with both, and the useful rule is that anything a consumer expects to be current belongs on the wrapper or on a live port, while anything analytical belongs on the engine.

What freshness should a consumer expect from a snapshot join?

Whatever each side’s snapshot cadence provides, which is why per-side provenance is mandatory in the result. A join reading a daily hydrology snapshot and a quarterly cadastral one produces an answer whose sides were materialized weeks apart, and no amount of engine capability changes that. The consumer needs the materialization timestamp of both sides to judge it, and a consumer who needs tighter agreement needs either a live port or a coordinated publish — not a faster query.