Trino & Presto GeoParquet Connector
Trino (and its Presto ancestor) earns its place in a geospatial stack when a query has to span more than one system: GeoParquet in object storage joined to a live PostGIS reference table, or several buckets of parcel and imagery-footprint data federated behind one SQL surface. Unlike a serverless engine you rent by the byte, Trino is an engine you own and tune, which means the wins and the pitfalls are both in your hands — connector configuration, pushdown behaviour, and how splits map onto Parquet row groups all decide whether a spatial query saturates your workers or starves them. This guide sits inside the Query Engines & Cloud Analytics for GeoParquet topic area and walks the path from an empty catalog file to federated spatial joins.
The core reality is the same one that shapes every columnar spatial engine: GeoParquet stores geometry as Well-Known Binary, so Trino sees a varbinary column that you decode in SQL, and the engine’s pruning power comes from partitions and column statistics rather than the geometry bytes themselves. What Trino adds over a single-node reader is genuine distributed parallelism and cross-catalog federation. Configure the Hive connector correctly and align split granularity to your files, and a multi-terabyte spatial scan spreads evenly across the workers; misconfigure either and the same query serialises onto one worker or thrashes the scheduler with thousands of tiny splits.
Prerequisites
- A Trino cluster (or Presto 0.28x) at a version with the geospatial plugin — Trino 400+ is assumed here — with a coordinator and one or more workers.
- A Hive metastore (standalone HMS or AWS Glue via the Hive connector) to hold table and partition metadata.
- Object storage (S3, GCS, Azure Blob, or MinIO) holding GeoParquet 1.0+ files with geometry as WKB, ideally ZSTD-compressed per ZSTD compression levels for geospatial data.
- Columnar-format grounding: row groups, statistics, and pushdown, as covered in understanding Parquet columnar storage for GIS.
- A SQL client (Trino CLI, JDBC, or
trino-python-client>=0.7) for issuing DDL and queries.
Architectural Foundations
Trino separates coordination from execution. The coordinator parses SQL, builds a distributed plan, and enumerates splits — the atomic units of read work. Workers pull splits, read data through a connector, and exchange intermediate rows over the network. For GeoParquet the relevant connector is Hive, which resolves table metadata from the metastore and streams Parquet column chunks from object storage. Nothing about geometry is special to the connector; it reads a varbinary column like any other and hands the bytes to the engine, where the geospatial plugin’s ST_ functions decode and operate on them.
The performance story is entirely about how much data reaches a worker and how evenly the work divides. Three mechanisms govern this. Predicate pushdown filters partitions in the metastore and prunes row groups against Parquet min/max statistics before any column chunk is read. Projection pushdown ensures only referenced columns stream from storage, so selecting two attributes plus geometry from a wide table transfers a fraction of the bytes. Split generation decides parallelism: Trino divides each file into splits that map onto row-group boundaries, and the size of those row groups therefore sets how many workers can read a file at once.
The catch specific to spatial data is that pushdown cannot lean on the geometry column. WKB bytes sort lexicographically, not geographically, so a row group’s min/max on the geometry column tells the planner nothing about which features fall in a bounding box. Spatial pruning has to be expressed some other way — partition columns keyed on region or tile, or a materialised bounding-box column (xmin, ymin, xmax, ymax) whose numeric statistics do prune row groups. This mirrors the spatial partitioning with quadtree indexes principle: locality must be encoded in something the engine can prune on. The diagram traces a federated spatial query through these stages.
Step-by-Step Workflow
1. Configure the Hive Connector
A Trino catalog is a properties file dropped into etc/catalog/. The file names the connector, the metastore, and the object-storage credentials. This is the foundation everything else builds on; the configuring the Trino Hive connector for GeoParquet companion covers every property in detail.
# etc/catalog/geo.properties — Trino 400+
connector.name=hive
hive.metastore=thrift
hive.metastore.uri=thrift://hms.internal:9083
fs.native-s3.enabled=true
s3.endpoint=https://s3.us-west-2.amazonaws.com
s3.region=us-west-2
hive.parquet.use-column-names=true
parquet.use-bloom-filter=true
Setting hive.parquet.use-column-names=true is important for GeoParquet: it matches columns by name rather than ordinal, so a geometry column added or reordered upstream still resolves correctly.
2. Define the External Table
Declare the table over the GeoParquet location. The geometry column is varbinary — Trino’s name for the Parquet BYTE_ARRAY holding WKB. Partition columns live in the path, exactly as with any Hive-style layout.
CREATE TABLE geo.spatial.parcels (
parcel_id varchar,
land_use varchar,
xmin double, -- materialised bbox for row-group pruning
ymin double,
xmax double,
ymax double,
geometry varbinary,
region varchar
)
WITH (
external_location = 's3://geo-lake/parcels/',
format = 'PARQUET',
partitioned_by = ARRAY['region']
);
The xmin..ymax columns are the trick that gives spatial pushdown teeth: their numeric statistics let Trino prune row groups by bounding box even though the geometry column cannot.
3. Decode Geometry and Apply Spatial Functions
Trino’s geospatial plugin provides the OGC ST_ function set. Decode WKB once, then filter. Keep the partition and bbox predicates ahead of the spatial refine so pushdown does the heavy lifting.
SELECT parcel_id, land_use
FROM geo.spatial.parcels
WHERE region = 'us-west' -- partition prune
AND xmin < -122.3 AND xmax > -122.5 -- bbox row-group prune
AND ymin < 37.9 AND ymax > 37.7
AND ST_Intersects( -- exact refine
ST_GeomFromBinary(geometry),
ST_GeomFromText('POLYGON((-122.5 37.7, -122.3 37.7,
-122.3 37.9, -122.5 37.9,
-122.5 37.7))'));
The bounding-box predicate is a cheap numeric filter that Trino pushes into row-group pruning; ST_Intersects then runs only on the survivors, so the expensive geometry maths touches a fraction of the rows.
4. Tune Pushdown and Split Behaviour
Split sizing decides parallelism. Each split maps onto Parquet row groups, so row-group size upstream governs how evenly Trino spreads a file across workers. Undersized row groups spawn thousands of tiny splits and scheduling dominates; a single giant row group serialises the file onto one worker. Align your writer to 128–512 MB row groups per the row group sizing strategies for Parquet guide, then confirm Trino is pruning as expected with EXPLAIN ANALYZE.
EXPLAIN ANALYZE
SELECT count(*) FROM geo.spatial.parcels
WHERE region = 'us-west' AND xmin > -123 AND xmax < -121;
-- Inspect "input rows" vs "rows" per stage: a large gap at the
-- TableScan means row-group pruning is working. Check
-- 'physical input' bytes to confirm projection pushdown too.
Session properties fine-tune this per query — for example SET SESSION geo.parquet_max_read_block_size and the split concurrency knobs — without editing the catalog file.
5. Run Federated Spatial Joins
Federation is Trino’s differentiator. Because catalogs coexist in one query, you can join GeoParquet in object storage against a live PostGIS table without an ETL step. Push filtering into each source first; the spatial join runs in the Trino workers and its cost scales with how many geometries survive pushdown.
SELECT p.parcel_id, z.zone_name
FROM geo.spatial.parcels p
JOIN postgis.public.zoning z
ON ST_Intersects(
ST_GeomFromBinary(p.geometry),
ST_GeomFromText(ST_AsText(z.geom)))
WHERE p.region = 'us-west'
AND z.city = 'San Francisco';
Both source predicates (region, city) push into their respective connectors, so only San Francisco zoning polygons and us-west parcels reach the join. For very large joins, broadcast the smaller side and pre-filter both by a shared bounding box.
Production-Ready Implementation
The helper submits a spatial query through the Trino Python client, retries on transient coordinator errors, and reports the physical bytes read so you can watch pushdown effectiveness in production.
# python>=3.10, trino[sqlalchemy]>=0.328, tenacity>=8.2
from __future__ import annotations
import logging
from dataclasses import dataclass
import trino
from tenacity import retry, stop_after_attempt, wait_exponential
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class ScanStats:
rows: int
physical_input_bytes: int
@retry(stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=2, max=20))
def run_spatial_query(
sql: str,
host: str,
catalog: str = "geo",
schema: str = "spatial",
user: str = "analytics",
port: int = 8443,
) -> tuple[list[tuple], ScanStats]:
"""Execute a Trino spatial query and return rows plus scan statistics.
Args:
sql: A SELECT decoding geometry with ST_GeomFromBinary.
host: Trino coordinator hostname.
catalog: Catalog holding the Hive-connector GeoParquet table.
schema: Default schema for unqualified names.
Raises:
trino.exceptions.TrinoQueryError: On a non-retryable query error.
"""
conn = trino.dbapi.connect(
host=host, port=port, user=user,
catalog=catalog, schema=schema,
http_scheme="https",
)
try:
cur = conn.cursor()
cur.execute(sql)
rows = cur.fetchall()
stats = cur.stats or {}
scan = ScanStats(
rows=len(rows),
physical_input_bytes=int(stats.get("physicalInputBytes", 0)),
)
logger.info("Returned %d rows; physical input %.2f GB",
scan.rows, scan.physical_input_bytes / 1024**3)
return rows, scan
except trino.exceptions.TrinoQueryError as exc:
# 5xx/coordinator failures are retryable; user errors are not.
if exc.error_name in {"NO_NODES_AVAILABLE", "REMOTE_TASK_ERROR"}:
logger.warning("Transient Trino error, retrying: %s", exc)
raise
logger.error("Non-retryable Trino error: %s", exc)
raise
finally:
conn.close()
Reference: Connector and Layout Choices
Representative behaviour for a 2 TB parcel-plus-zoning workload on a 10-worker Trino 430 cluster. Numbers depend on hardware, network, and geometry complexity.
| Configuration | Split count (per TB) | Spatial scan latency | Pushdown quality | Primary use case |
|---|---|---|---|---|
| Hive connector, no bbox columns | 4k–8k | 60–140 s | Weak (geometry only) | Legacy tables, ad-hoc exploration |
| Hive connector + bbox columns | 4k–8k | 12–30 s | Strong (numeric prune) | Interactive bounding-box analytics |
| Hive + partition by region/tile | 0.5k–2k | 6–16 s | Strong (partition + bbox) | Recurring regional spatial reports |
| Iceberg connector (Parquet) | 0.5k–2k | 6–15 s | Strong + hidden partitioning | Managed lakehouse tables |
| Federated Hive + PostGIS join | varies | 10–40 s | Depends on source pushdown | Enriching lake data with live reference |
Failure Modes and Gotchas
| Anti-pattern | Symptom | Fix |
|---|---|---|
Geometry typed as varchar |
Spatial predicates return nothing; no error raised | Declare geometry as varbinary so WKB reaches ST_GeomFromBinary |
| No bounding-box columns | ST_Intersects scans whole partitions; slow queries |
Materialise xmin/ymin/xmax/ymax so numeric stats prune row groups |
| Tiny Parquet row groups | Thousands of small splits, scheduler-bound queries | Rewrite to 128–512 MB row groups; recheck split count in EXPLAIN ANALYZE |
| One giant row group per file | Query serialises onto a single worker | Cap row group size so each file yields several evenly sized splits |
| Column-by-ordinal matching | Wrong column read after upstream schema reorder | Set hive.parquet.use-column-names=true in the catalog |
| Unfiltered federated join | Whole PostGIS table pulled into Trino before join | Push source predicates into every catalog; pre-filter by shared bbox |
Frequently Asked Questions
Which connector should I use for GeoParquet in Trino?
Use the Hive connector pointed at a metastore and object storage. It reads Parquet with full predicate and projection pushdown and treats the GeoParquet geometry column as varbinary, which you decode with ST_GeomFromBinary. The Iceberg and Delta Lake connectors also read Parquet and are preferable if your data is already in those table formats, but for plain GeoParquet files in a bucket the Hive connector is the direct path.
Does Trino push spatial predicates down into Parquet?
Trino pushes down partition and column predicates and prunes row groups on min/max statistics, but the ST_ spatial predicate itself runs in the engine after decompression. Because a WKB geometry column has no useful geographic min/max, spatial pruning must come from partition columns or a bounding-box column you materialise alongside the geometry. Project only needed columns and filter on partitions first to keep splits small.
How does split size relate to Parquet row groups?
A split is the unit of parallel work Trino hands to a worker, and for Parquet it maps onto one or more row groups within a file. If row groups are tiny, Trino generates many small splits and scheduling overhead dominates; if a file is one giant row group, parallelism collapses to a single split. Aligning row group size to 128 to 512 MB gives Trino evenly sized splits that keep every worker busy.
Can Trino join GeoParquet against a live PostGIS database?
Yes. Trino federates across catalogs, so a single query can join a Hive-connector GeoParquet table against a PostgreSQL/PostGIS catalog. Decode both geometries to a common representation and join on a spatial predicate or a shared key. Push as much filtering as possible into each source first, because the spatial join itself runs in Trino and its cost grows with the number of geometries that survive pushdown.
Related
- Configuring the Trino Hive Connector for GeoParquet
- AWS Athena Spatial Queries on GeoParquet
- DuckDB Spatial Extension for GeoParquet
- Row Group Sizing Strategies for Parquet
- Spatial Partitioning with Quadtree Indexes
← Back to Query Engines & Cloud Analytics for GeoParquet