22 min read

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.


Query engines reading GeoParquet in place from cloud object storage A single GeoParquet dataset in object storage, laid out with Hilbert sorting, covering bounding-box statistics, and Hive-style partitions, is read directly by three query engines: DuckDB for single-node interactive analysis, AWS Athena for serverless ad hoc SQL, and Trino or Presto for a shared high-concurrency cluster. Each engine applies partition pruning, row-group skipping, and projection pushdown so only relevant bytes are transferred. Object storage GeoParquet dataset Hilbert-sorted rows covering bbox stats Hive-style partitions ZSTD column chunks Pushdown prune · skip · project only bytes needed DuckDB single-node interactive · richest ST_ set notebooks, laptops, one big VM AWS Athena serverless · billed per TB scanned ad hoc, no cluster to manage Trino / Presto shared cluster · high concurrency petabyte lakes, many users no load step — the file is the table

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.

Three-stage pushdown for a spatial GeoParquet query A spatial SQL query enters at the top. Stage one, partition pruning, drops non-matching Hive partition directories using path predicates before any file opens. Stage two, row-group skipping, reads Parquet footers and compares each row group's covering bounding box against the query window, keeping only overlapping groups. Stage three, projection pushdown, fetches only the column chunks named in the SELECT list. The result is a small set of transferred bytes fed to the spatial predicate. Spatial SQL query WHERE region='eu' AND ST_Intersects(...) 1 · Partition pruning Path predicate drops non-matching directories before any file opens region=eu/ kept · region=us/ · region=ap/ eliminated 100% → 12% 2 · Row-group skipping Footer bbox min/max compared to query window; non-overlapping groups skipped reads 3 of 40 row groups in the surviving files 12% → 2% 3 · Projection pushdown Only column chunks in the SELECT list are transferred and decompressed id, geometry read · 38 attribute columns untouched 2% → 0.4%

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.

Read path: engine to object storage for an in-place GeoParquet query The query planner first lists the partition prefix and prunes directories. The reader then issues a small HTTP range request for each surviving file's Parquet footer to load schema and row-group statistics. It evaluates the covering bounding box against the query window, selects overlapping row groups, and issues targeted range requests only for the column chunks in the projection. Decompression and the spatial predicate run locally on the transferred bytes. Query engine 1. list + prune prefix 2. read footer stats 3. evaluate bbox vs query window 4. fetch chunks 5. decode + predicate Object storage footer (schema+stats) ~KB range read row group 7 · MATCH row group 8 · skip row group 9 · skip only matching chunks leave the bucket footer range request stats returned chunk range requests

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

Engine selection decision flowchart A decision tree. First: does the working set fit one large VM? If yes, use DuckDB with the spatial extension. If no, ask whether queries are frequent and concurrent. If they are not frequent, use AWS Athena as serverless SQL billed per terabyte scanned. If they are frequent and concurrent, ask whether the workload is dominated by large distributed spatial joins. If yes, use Apache Sedona on Spark. If no, run a standing Trino or Presto cluster over the lake. Fits one large VM? working-set size Yes DuckDB + spatial extension richest ST_ set · zero infrastructure · in-process No Frequent & concurrent? query cadence No AWS Athena (serverless) scale-to-zero · billed per TB scanned · no cluster Yes Big distributed joins? join footprint Yes Apache Sedona on Spark distributed spatial join optimizer · executors scale out No Trino / Presto standing cluster shared high-concurrency lake access over Hive connector many analysts, petabyte scale

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.

python
# 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.


Continue exploring