Query Engines & Cloud Analytics for GeoParquet
For GIS data engineers and cloud architects, the promise of GeoParquet is that the file is the table: no ingestion into a spatial database, no nightly load job, no second copy to keep in sync. A query engine reaches into object storage, reads a footer, and scans only the bytes a query actually needs. The architectural challenge is that this promise only holds when the physical layout of the data and the pushdown capabilities of the engine are designed together. Point a naive SELECT * at an unsorted, unpartitioned dataset and every engine degrades to a full-bucket scan — the exact cost profile you adopted a columnar format to avoid.
This guide covers the engines that query GeoParquet in place — DuckDB with its spatial extension, AWS Athena, and Trino/Presto, with Apache Sedona on Spark and BigQuery framing the wider landscape — and the mechanics that make them fast and cheap: predicate and projection pushdown against Parquet footers, bounding-box statistics and Hilbert-sorted files that let engines skip row groups, partition pruning, CRS handling inside SQL, and the discipline of measuring bytes scanned rather than wall-clock time. The three sub-sections below go deep on each engine; this page is the map that connects them.
Four Sub-Problems Every In-Place Query Layer Must Solve
Querying GeoParquet in place decomposes into four coupled problems. Solving one in isolation — for instance, adding partitions but never sorting within files — leaves most of the potential speed-up on the table.
Getting the engine to read less. The entire value proposition rests on the engine reading a small fraction of the dataset per query. This depends on the Parquet footer: min/max statistics and, in GeoParquet 1.1, a covering-bbox column that lets the engine compare each row group’s spatial extent against the query window. Files that are Hilbert-sorted before writing cluster nearby features into the same row groups, so a small query window overlaps few groups. Without that ordering, every group’s bbox spans the whole dataset and skipping collapses to zero. The engine also honours column pruning in Parquet: a SELECT id, geometry reads two column chunks, not forty.
Choosing the right engine for the workload. DuckDB, Athena, and Trino share the same on-disk format but occupy different operational niches. The DuckDB spatial extension for GeoParquet turns a single process into a full spatial SQL engine with no server; it is unbeatable for interactive work and pipelines that fit one machine. The AWS Athena path for spatial queries on GeoParquet trades per-terabyte billing for zero infrastructure and effortless scale-to-zero. The Trino and Presto GeoParquet connector fronts a shared lake for many concurrent users at petabyte scale.
Managing coordinate reference systems inside SQL. SQL engines treat coordinates as numbers. They do not re-project inside a predicate, and a mixed-CRS dataset produces incomparable bbox statistics that break row-group skipping. CRS normalization must happen upstream during conversion; the query layer only consumes a single, declared CRS recorded in the GeoParquet geo metadata footer.
Controlling cost through bytes scanned. Cloud analytics cost is a function of bytes moved and CPU spent, not query count. Codec choice feeds this directly: selecting an appropriate ZSTD compression level shrinks the compressed bytes an engine transfers per row group, and row group sizing sets the granularity at which skipping operates. These are query-performance decisions as much as storage decisions.
How Pushdown Turns a Full Scan into a Targeted Read
The mechanism that makes in-place querying viable is pushdown: the engine “pushes” filters and column selection down to the storage layer so it never materializes rows it will discard. The diagram traces a single spatial query through the three pushdown stages, from a partitioned bucket down to the handful of column chunks that satisfy the predicate.
The percentages are illustrative but the shape is real: each stage multiplies the reduction of the previous one. Partition pruning is coarse and free — it is pure string matching on object keys. Row-group skipping is finer and depends entirely on how well the data was spatially sorted before it was written; a randomly ordered file defeats it. Projection pushdown is orthogonal to both and applies even to unsorted data. The compounding is why a query touching 0.4% of a dataset is routine on a well-built table and impossible on a poorly-built one, even though both hold identical rows.
Format and Engine Landscape
The engines below all read the same GeoParquet bytes but differ in deployment model, spatial-function coverage, and pricing. The table frames where each earns its place.
| Engine | Primary Use Case | Deployment Model | Spatial Function Coverage | Cost Model |
|---|---|---|---|---|
| DuckDB + spatial | Interactive analysis, notebooks, single-node ETL | Embedded library, one process | Rich ST_ set via GEOS, GDAL read/write |
Free; you pay only for the VM |
| AWS Athena (Trino-based) | Ad hoc, low-frequency serverless SQL | Fully managed, scale-to-zero | Presto/Trino geospatial ST_ functions |
Per TB scanned |
| Trino / Presto | Shared high-concurrency lake access | Standing cluster you operate | Geospatial connector ST_ functions |
Cluster compute + storage I/O |
| Apache Sedona (Spark) | Distributed spatial joins beyond one node | Spark cluster, partitioned RDD/DataFrame | Extensive ST_ + spatial join optimizer |
Cluster compute |
| Google BigQuery | Warehouse-native GEOGRAPHY analytics |
Fully managed serverless | ST_ on spherical GEOGRAPHY type |
Per TB scanned (or slots) |
Selection criteria. Reach for DuckDB when a large VM can hold the working set and you want the shortest path from file to answer — this is the DuckDB spatial extension territory. Choose Athena when queries are sporadic and you refuse to babysit a running engine; the entire game there is partitioning to control bytes scanned. Run Trino when many analysts hit the same lake continuously and a standing deployment amortizes across them, which is where the Hive connector configuration matters. Sedona and BigQuery bound the landscape: Sedona for spatial joins that genuinely need distribution, BigQuery when the organization already standardizes on its warehouse and the spherical GEOGRAPHY model fits.
Architecture: The Read Path from Object Storage
Understanding cost and latency means understanding the exact sequence of object-storage requests an engine issues. The diagram below shows the read path for a bbox query against a partitioned, Hilbert-sorted GeoParquet dataset: a small footer read, a statistics evaluation, and then targeted range requests for the surviving column chunks.
The crucial detail is that the footer read is tiny and cheap, but it unlocks all subsequent skipping. An engine that cannot read Parquet statistics — or a file written without them — must fall back to reading every byte. This is why tuning row group size for cloud query performance is a query-layer concern: row groups that are too large make each “match” expensive because the smallest readable unit is one whole group, while groups that are too small bloat the footer and add per-request overhead against object storage.
Performance and Trade-off Analysis
Cost and Concurrency by Engine
| Engine | Bytes-Scanned Sensitivity | Concurrency Ceiling | Cold-Start Latency | Primary Use Case |
|---|---|---|---|---|
| DuckDB (single node) | Bound by VM network + RAM | 1 process (threads within) | None (in-process) | Interactive, notebooks |
| Athena | Direct: billed per TB scanned | High (managed, per-account limits) | Seconds (query queue) | Ad hoc serverless |
| Trino / Presto | Indirect: caps cluster throughput | Very high (add workers) | None (cluster warm) | Shared analytics |
| Sedona (Spark) | Indirect: shuffle + scan | Very high (executors) | Tens of seconds (job start) | Distributed joins |
Layout Decisions That Govern Query Cost
| Layout Lever | Effect on Bytes Scanned | Cost to Apply | Primary Use Case |
|---|---|---|---|
| Hive partition columns | Coarse pruning of whole directories | Cheap; choose low-cardinality keys | Time/region-sliced dashboards |
| Hilbert row sort | Enables fine row-group skipping | Full re-sort on write | Bbox-selective spatial queries |
| Covering bbox column | Lets engine skip on geometry extent | Written at export time | Any spatial predicate workload |
| Row group size 32–64 MB | Finer skip granularity | Slightly larger footers | High-selectivity point/bbox reads |
| ZSTD level 3 | Fewer compressed bytes per group | Modest write CPU | Remote reads over object storage |
Which Engine for Which Workload
Production Implementation
The pattern below is a DuckDB reader for partitioned GeoParquet in S3. It configures httpfs from environment credentials, pushes a partition predicate and a bbox predicate into the scan, projects a narrow column list, and reports the bytes scanned so cost is observable in the pipeline itself. The same SQL body ports to Athena and Trino with minor dialect changes.
# Requires: duckdb>=0.10, pyarrow>=14.0 (Python 3.10+)
from __future__ import annotations
import os
from dataclasses import dataclass
import duckdb
@dataclass(frozen=True)
class BBox:
"""Query window in the dataset's declared CRS (assumed EPSG:4326)."""
min_x: float
min_y: float
max_x: float
max_y: float
def query_partitioned_geoparquet_s3(
s3_glob: str,
region_partition: str,
window: BBox,
*,
columns: tuple[str, ...] = ("id", "name", "geometry"),
aws_region: str = "eu-west-1",
) -> tuple[list[tuple], int]:
"""Query Hilbert-sorted, partitioned GeoParquet in S3 with pushdown.
Parameters
----------
s3_glob:
Glob over the partitioned dataset, e.g.
's3://bucket/parcels/region=*/*.parquet'.
region_partition:
Value for the Hive 'region' partition column to prune to.
window:
Bounding box for the spatial predicate, in the file CRS.
columns:
Projection list. Keep it minimal to reduce bytes scanned.
aws_region:
AWS region for the httpfs S3 client.
Returns
-------
(rows, bytes_scanned) where bytes_scanned is DuckDB's reported
total bytes read from object storage for the query.
"""
if not {"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"} <= os.environ.keys():
raise RuntimeError("AWS credentials must be set in the environment")
con = duckdb.connect()
try:
con.execute("INSTALL httpfs; LOAD httpfs;")
con.execute("INSTALL spatial; LOAD spatial;")
con.execute(f"SET s3_region='{aws_region}';")
con.execute("SET s3_access_key_id=getenv('AWS_ACCESS_KEY_ID');")
con.execute("SET s3_secret_access_key=getenv('AWS_SECRET_ACCESS_KEY');")
con.execute("SET enable_object_cache=true;")
projection = ", ".join(columns)
sql = f"""
SELECT {projection}
FROM read_parquet(?, hive_partitioning => true)
WHERE region = ?
AND bbox.xmin <= ? AND bbox.xmax >= ?
AND bbox.ymin <= ? AND bbox.ymax >= ?
AND ST_Intersects(
geometry,
ST_MakeEnvelope(?, ?, ?, ?)
)
"""
params = [
s3_glob,
region_partition,
window.max_x, window.min_x, # bbox overlap test (pruning)
window.max_y, window.min_y,
window.min_x, window.min_y, # exact predicate
window.max_x, window.max_y,
]
rows = con.execute(sql, params).fetchall()
stats = con.execute(
"SELECT bytes_read FROM duckdb_profiling_output()"
).fetchone()
bytes_scanned = int(stats[0]) if stats and stats[0] else 0
return rows, bytes_scanned
except duckdb.IOException as exc:
raise RuntimeError(f"S3 read failed for {s3_glob}: {exc}") from exc
finally:
con.close()
Two design points carry most of the value. First, the bbox.xmin/bbox.xmax predicates on the GeoParquet covering-bbox column let DuckDB skip row groups from footer statistics before the more expensive ST_Intersects call runs on the surviving rows — the cheap filter guards the exact one. Second, the projection is passed as an explicit column tuple, so the reader never transfers the wide attribute columns a SELECT * would drag across the network. Returning bytes_scanned alongside the rows makes cost a first-class output you can assert on in tests and alerts.
CRS Governance and Cost Controls
Declare one CRS and enforce it upstream. SQL spatial functions operate on raw coordinates. A query engine will happily run ST_Distance across two files in different projections and return a meaningless number. Normalize to a single CRS during conversion, record it in the GeoParquet geo metadata, and reject files at ingestion whose declared CRS differs. The understanding Parquet columnar storage reference covers where this metadata lives in the footer.
Budget by bytes scanned, not query count. On Athena, attach a per-workgroup data-scanned limit so a runaway SELECT * cannot silently bill for a full-bucket scan. On Trino, cap query.max-scan-physical-bytes. On DuckDB, wrap queries in the byte-reporting pattern above and alert when the scan-to-return ratio exceeds a threshold. In all three, the fix for a bloated scan is the same: partition better, sort better, or project narrower.
Keep partition cardinality sane. Hive partitioning on a high-cardinality column (say, a raw timestamp) explodes into millions of tiny files, and the per-file footer and request overhead swamps any pruning benefit. Partition on coarse, low-cardinality keys — region, month, dataset version — and rely on Hilbert sorting plus row-group skipping for fine-grained selectivity within each partition.
Match compression to the read path. For remote object-storage reads, ZSTD level 3 usually wins because decompression outruns the network and the smaller payload dominates. For Athena specifically, bytes scanned is measured on the compressed size, so a higher level directly lowers the bill on cold, infrequently-queried tables.
Selection Framework
| Workload | Data Scale | Access Pattern | Recommended Engine | Layout Priority | Primary Use Case |
|---|---|---|---|---|---|
| Notebook exploration | < 200 GB | Ad hoc, iterative | DuckDB + spatial | Hilbert sort + bbox column | Interactive analysis |
| Nightly bbox extract | 1–50 GB | Scheduled, selective | DuckDB or Athena | Partition + row-group skip | Pipeline extracts |
| Sporadic analyst SQL | Any | Rare, unpredictable | Athena | Partition pruning first | Serverless ad hoc |
| Shared dashboard backend | 100 GB–PB | Continuous, concurrent | Trino / Presto | Partition + sort + cache | Multi-user analytics |
| Continent-scale join | > 1 TB | Distributed spatial join | Sedona on Spark | Spatial partition alignment | Large geometry joins |
| Warehouse-native BI | Any | SQL in existing DWH | BigQuery | GEOGRAPHY clustering | Standardized reporting |
Operational Resilience
In-place querying pushes new failure modes to the surface that a loaded database would have hidden at ingestion time. Guard against them explicitly.
Detect layout drift. As data is appended, Hilbert locality decays: new rows land at the end, their bboxes overlap everything, and row-group skipping quietly degrades. Monitor the scan-to-return ratio per recurring query; a rising trend at stable query volume signals it is time to re-sort. Rebuild the spatial order when a dataset grows by more than roughly 20% through unsorted appends.
Validate statistics on write. A GeoParquet file written without per-column statistics or without the covering-bbox column silently disables skipping — the engine still returns correct answers, just by reading everything. Assert that write_statistics and the bbox column are present in a post-write check, and fail the pipeline if they are missing rather than discovering it as a cost spike weeks later.
Pin engine and connector versions. Spatial function semantics and pushdown behaviour evolve across DuckDB, Trino, and Athena releases. Pin versions in your query environment and re-benchmark on upgrade; a connector that stops pushing a predicate down turns a 2% scan into a 100% scan with no error message.
Frequently Asked Questions
Do I need a database to run SQL over GeoParquet in the cloud?
No. DuckDB, Athena, and Trino all read GeoParquet files directly from object storage without a load step. The files are the table. You point the engine at an S3 prefix, register the CRS-normalized schema, and issue spatial SQL. A managed database only pays off when you need high-concurrency single-row lookups or transactional writes, which columnar scan engines are not built for.
How does a query engine skip data it does not need in a GeoParquet file?
Three mechanisms stack. Partition pruning drops entire directories using Hive-style path predicates before any file is opened. Row-group skipping reads the Parquet footer and compares each row group’s min/max and covering bbox statistics against the query window, reading only overlapping groups. Projection pushdown then fetches only the column chunks named in the SELECT list. Together they can cut bytes scanned by one to two orders of magnitude on a well-laid-out dataset.
Why does bytes scanned matter more than query wall-clock time in cloud analytics?
On serverless engines like Athena you are billed per terabyte scanned, so bytes scanned is the direct cost driver — a query that runs in four seconds but scans 400 GB is expensive whether or not it feels fast. On cluster engines like Trino, bytes scanned governs how much object-storage bandwidth and CPU the query consumes, which caps concurrency. Wall-clock time is a symptom; bytes scanned is the lever you actually control through layout.
How should CRS be handled when querying GeoParquet with SQL?
Normalize every file to a single CRS before you write it, and record that CRS in the GeoParquet geo metadata. SQL engines treat coordinates as plain numbers; they will not re-project for you inside a spatial predicate. If you mix CRS across files, bbox statistics become incomparable, row-group skipping silently returns wrong results, and ST_ distance functions compute nonsense. Do the projection upstream in the pipeline, not in the query.
Which engine should I choose for a spatial GeoParquet workload?
Use DuckDB for single-node interactive analysis, notebooks, and datasets that fit a large VM; it has the richest ST_ function set and zero infrastructure. Use Athena for ad hoc, low-frequency queries where you want no cluster to manage and can accept per-terabyte pricing. Use Trino or Presto for shared, high-concurrency access to petabyte-scale lakes where a standing cluster is justified. Sedona on Spark fits heavy distributed spatial joins that exceed a single node.
Does compression level change how much data the engine transfers?
Yes. The engine transfers compressed bytes over the network and decompresses them locally, so a higher ZSTD level means fewer bytes on the wire per row group at the cost of more CPU per decode. For remote reads over object storage, ZSTD level 3 is the usual sweet spot: decompression runs faster than the network delivers bytes, so the smaller payload wins. Bytes scanned on Athena, however, is measured on the compressed on-disk size, so better compression directly lowers the bill.
Related
- DuckDB Spatial Extension for GeoParquet — single-node in-place querying with the richest ST_ function set
- AWS Athena Spatial Queries on GeoParquet — serverless SQL and partitioning for per-terabyte cost control
- Trino & Presto GeoParquet Connector — shared high-concurrency lake access over the Hive connector