AWS Athena Spatial Queries on GeoParquet

AWS Athena is attractive for geospatial analytics because it is serverless: there is no cluster to size, and you pay only for the bytes each query scans. That billing model is also its sharpest edge. A single unqualified SELECT against a terabyte of parcel geometry costs real money, and a table whose partitions are laid out badly can scan ten times more data than the query actually needs. Running spatial workloads on Athena well is therefore less about the ST_ functions — which are a solved problem in engine v3 — and more about controlling what the reader touches on S3. This guide is part of the Query Engines & Cloud Analytics for GeoParquet topic area, and it focuses on the path from a raw GeoParquet export in a bucket to a cost-predictable spatial query surface.

The work divides into three concerns: registering the data so Athena can find and type it, decoding Well-Known Binary geometry so spatial predicates work, and shaping the physical layout so the bytes-scanned bill stays proportional to the query. Get the first two wrong and queries fail outright; get the third wrong and they succeed while quietly burning budget. We treat all three, then hand off the deep partitioning discussion to a focused companion on partitioning GeoParquet for Athena cost control.


Prerequisites

  • AWS account with Athena engine version 3 selected on the workgroup, plus S3 read access to the GeoParquet prefix and write access to a query-result location.
  • A Glue Data Catalog database (or Lake Formation equivalent) to hold the external table definition.
  • GeoParquet 1.0+ files in S3 with geometry stored as WKB and, ideally, ZSTD compression — see ZSTD compression levels for geospatial data for codec selection.
  • Familiarity with Parquet internals: row groups, column statistics, and predicate pushdown, covered in understanding Parquet columnar storage for GIS.
  • Python 3.10+ with boto3>=1.34 and awswrangler>=3.5 if you intend to script table creation and CTAS runs rather than use the console.

Architectural Foundations

Athena is a managed deployment of Trino (formerly PrestoDB) with AWS-specific connectors bolted on. Queries compile to a distributed plan that reads directly from S3; there is no persistent storage layer of its own. The Glue Data Catalog supplies schema and partition metadata, and the S3 objects supply the data. Understanding where each cost and each failure lives means understanding that split.

When Athena plans a query it first consults Glue for the table’s columns, its partition scheme, and — if partitions are materialised — their S3 locations. It then prunes partitions against the WHERE clause, opens the Parquet footers of the surviving files, prunes row groups against column statistics, and only then streams the needed column chunks over the network to decompress and filter. Every byte that reaches that final stage is billed. Because GeoParquet stores geometry as a single opaque WKB column, min/max statistics on that column are effectively a bounding box in byte space, not geographic space — so row-group pruning on geometry alone is weak. This is why spatial layout has to be expressed through partition columns and sort order, not left to the geometry column to sort out.

The geometry itself arrives as binary. GeoParquet’s specification stores each feature’s geometry as WKB inside a standard Parquet BYTE_ARRAY column, with a file-level metadata key describing the encoding and CRS. Athena’s reader ignores that metadata block; it sees only a binary column. The decode therefore happens in SQL: ST_GeomFromBinary(geometry) lifts the bytes into a geometry value that engine v3’s spatial functions operate on. This is the single most common stumbling point — declaring the column as string instead of binary corrupts the WKB and every spatial predicate silently returns nothing.

The diagram below traces a spatial query from submission to billed bytes, showing where pruning happens and where the ST_ predicate sits.

Athena spatial query execution path and where bytes are billed A left-to-right flow showing a SQL query passing through Glue partition pruning, Parquet row-group pruning, column projection, then decompression and the spatial ST predicate, with a note that only the decompressed columns count as billed bytes scanned. SQL query WHERE + ST_ predicate Glue pruning partitions dropped Footer pruning row groups dropped Column projection Billed bytes scanned Decompress column chunks ST_ predicate runs free Result rows to result S3 Cost lever Everything left of the dashed box removes bytes before billing. The spatial predicate cannot: it runs after decompression, on data that is already counted. Prune with partitions and columns, not ST_.

Step-by-Step Workflow

1. Register the GeoParquet Table in Glue

Create an external table pointing at the S3 prefix that holds your GeoParquet files. The critical detail is the geometry column type: declare it binary, never string. Athena maps binary to the Parquet BYTE_ARRAY that holds WKB, and any other type mangles the bytes.

sql
-- Athena engine v3, Glue Data Catalog
CREATE EXTERNAL TABLE parcels (
    parcel_id   string,
    land_use    string,
    assessed    double,
    geometry    binary          -- WKB from GeoParquet
)
PARTITIONED BY (region string, ingest_date string)
STORED AS PARQUET
LOCATION 's3://geo-lake/parcels/'
TBLPROPERTIES ('parquet.compression' = 'ZSTD');

The PARTITIONED BY clause names columns that live in the S3 path, not in the file bodies. Choosing them well is the single biggest cost decision you will make; the companion page on partitioning GeoParquet for Athena cost control works through key selection in depth.

2. Enable Partition Projection

Without projection, Athena must discover partitions either by running MSCK REPAIR TABLE (which lists every prefix) or by registering each partition in Glue. On a spatial table tiled to millions of cells, both are painfully slow. Partition projection instead computes the partition values from the query predicate using rules declared in table properties.

sql
ALTER TABLE parcels SET TBLPROPERTIES (
    'projection.enabled' = 'true',
    'projection.region.type' = 'enum',
    'projection.region.values' = 'us-west,us-east,eu-central,ap-south',
    'projection.ingest_date.type' = 'date',
    'projection.ingest_date.range' = '2020-01-01,NOW',
    'projection.ingest_date.format' = 'yyyy-MM-dd',
    'storage.location.template' =
        's3://geo-lake/parcels/${region}/${ingest_date}/'
);

Now a query with WHERE region = 'us-west' AND ingest_date > '2026-01-01' reads only the matching prefixes — Athena never lists the bucket. Projection is also the only practical way to serve tables whose partition count exceeds Glue’s per-table limits.

3. Decode WKB and Apply Spatial Predicates

With the table registered, spatial work is ordinary SQL. Decode the binary column once, then filter with engine v3’s ST_ functions. Always keep the partition predicate in the same WHERE clause so pruning still applies.

sql
SELECT parcel_id, assessed
FROM parcels
WHERE region = 'us-west'                       -- prunes partitions first
  AND ingest_date BETWEEN '2026-01-01' AND '2026-06-30'
  AND ST_Intersects(
        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 partition columns do the cost work; ST_Intersects refines the result within the surviving row groups. Reversing that dependency — filtering only on geometry — forces a full scan of the table because the WKB column has no useful geographic statistics.

4. Control Bytes Scanned

Athena’s bill equals bytes scanned times the per-terabyte rate. Two levers reduce it, and neither is the spatial function. First, prune partitions so entire prefixes never open. Second, project only the columns you need so unrelated column chunks are never streamed. Selecting assessed and geometry from a fifty-column table can cut bytes scanned by an order of magnitude versus SELECT *.

sql
-- Inspect the true cost of a query after it runs
SELECT
    bytes_scanned_cutoff_per_query,
    total_bytes_scanned,
    round(total_bytes_scanned / 1e12 * 5.0, 4) AS est_usd
FROM information_schema.query_history        -- illustrative; use GetQueryExecution in practice
WHERE query_id = '<execution-id>';

In production, read Statistics.DataScannedInBytes from the GetQueryExecution API to attribute cost per query. Set a bytes_scanned_cutoff_per_query on the workgroup as a hard ceiling so a runaway SELECT * cannot drain the budget.

5. Repartition with CTAS

Raw exports rarely match your query pattern. CREATE TABLE AS SELECT rewrites the data once into a layout tuned for cheap repeat reads: ZSTD compression, spatially sorted rows so nearby features share row groups, and partition columns matched to how analysts filter. This mirrors the spatial partitioning with quadtree indexes principle of co-locating features that are queried together.

sql
CREATE TABLE parcels_optimised
WITH (
    format = 'PARQUET',
    parquet_compression = 'ZSTD',
    partitioned_by = ARRAY['region'],
    bucketed_by = ARRAY['parcel_id'],
    bucket_count = 64
) AS
SELECT parcel_id, land_use, assessed, geometry, region
FROM parcels
ORDER BY ST_GeoHash(ST_GeomFromBinary(geometry));   -- spatial sort

The ORDER BY on a geohash groups spatially adjacent parcels into the same output files, so a later bounding-box query touches fewer row groups. Because CTAS output file count follows bucket_count, you also avoid the small-file explosion that would otherwise inflate S3 request costs.


Production-Ready Implementation

The helper below scripts the full path — create-if-absent, run a spatial query, and report the true scanned cost — using awswrangler, which wraps Athena’s start/poll/fetch cycle. It pins versions, types its signatures, and surfaces the scanned bytes so cost is never a surprise.

python
# python>=3.10, boto3>=1.34, awswrangler>=3.5, pandas>=2.1
from __future__ import annotations

import logging
from dataclasses import dataclass

import awswrangler as wr
import pandas as pd

logger = logging.getLogger(__name__)

PRICE_PER_TB_USD = 5.0


@dataclass(frozen=True)
class QueryCost:
    rows: int
    scanned_bytes: int

    @property
    def est_usd(self) -> float:
        # Athena rounds up to a 10 MB minimum per query.
        billable = max(self.scanned_bytes, 10 * 1024**2)
        return round(billable / 1024**4 * PRICE_PER_TB_USD, 4)


def run_spatial_query(
    sql: str,
    database: str,
    workgroup: str = "primary",
    max_scan_gb: float = 50.0,
) -> tuple[pd.DataFrame, QueryCost]:
    """Execute an Athena spatial query and return results plus scanned cost.

    Args:
        sql: A SELECT that decodes geometry with ST_GeomFromBinary.
        database: Glue database holding the external table.
        workgroup: Athena workgroup (should pin engine v3).
        max_scan_gb: Guardrail; raises if the query scans more than this.

    Raises:
        RuntimeError: If the query scans beyond the guardrail.
    """
    try:
        df = wr.athena.read_sql_query(
            sql,
            database=database,
            workgroup=workgroup,
            ctas_approach=False,
            keep_files=False,
        )
    except wr.exceptions.QueryFailed as exc:
        logger.error("Athena query failed: %s", exc)
        raise

    scanned = int(df.query_metadata["Statistics"]["DataScannedInBytes"])
    cost = QueryCost(rows=len(df), scanned_bytes=scanned)

    if scanned > max_scan_gb * 1024**3:
        raise RuntimeError(
            f"Query scanned {scanned / 1024**3:.1f} GB, "
            f"over the {max_scan_gb} GB guardrail — check partition pruning."
        )

    logger.info("Query returned %d rows, scanned %.2f GB (~$%.4f)",
                cost.rows, scanned / 1024**3, cost.est_usd)
    return df, cost

Reference: Athena Spatial Layout Choices

Representative figures for a 1.2 TB parcel dataset (180 M polygons, GeoParquet, ZSTD level 6) queried on Athena engine v3. Numbers vary with geometry complexity and query extent.

Layout Typical bytes scanned per bbox query Query latency Repeat-query cost Primary use case
Unpartitioned single prefix 900 GB–1.2 TB 40–90 s High One-off exploration only
Hive partitions, no projection 60–200 GB 15–40 s Medium Small partition counts, batch ETL
Partition projection by region + date 8–40 GB 6–18 s Medium-low Dashboards, recurring regional reports
CTAS: projected + geohash-sorted 1–12 GB 3–9 s Low High-frequency interactive spatial queries
CTAS + bucketing on join key 1–10 GB 3–8 s Low Federated joins, repeated point-in-polygon

Failure Modes and Gotchas

Anti-pattern Symptom Fix
Geometry column declared as string Every ST_ predicate returns zero rows; no error Redefine the column as binary so WKB bytes reach ST_GeomFromBinary intact
Filtering only on geometry, no partition predicate Full-table scan; bill 10–100x expected Always pair the ST_ filter with a partition-column predicate so Glue prunes prefixes first
Relying on geometry min/max for pushdown Row-group pruning does nothing; large scans persist Encode spatial locality as partition columns and CTAS sort order, not the WKB column
MSCK REPAIR TABLE on a million-partition table Query planning stalls for minutes Switch to partition projection; drop Glue-registered partitions entirely
Thousands of tiny GeoParquet files per partition High request cost, slow first byte, poor pushdown Repartition with CTAS and bucket_count to consolidate into 128–512 MB files
SELECT * on a wide table Scans every column chunk, inflating bytes billed Project only required columns; geometry plus the two or three attributes needed

Small-file overhead deserves emphasis: Athena opens each object’s footer separately, and object-level request costs plus per-file planning add up fast. Aligning output file size to your row group sizing strategy — targeting 128–512 MB files — keeps both pushdown and request costs healthy.


Frequently Asked Questions

Does Athena read GeoParquet geometry natively?

Not as a first-class geometry type. Athena reads the geometry column as binary and you decode it in SQL with ST_GeomFromBinary, because GeoParquet stores geometry as Well-Known Binary. Engine v3 then exposes the full ST_ function set over the decoded value. The Parquet metadata that marks the column as GeoParquet is ignored by the reader, so the table definition must declare the column as binary.

How is an Athena spatial query billed?

Athena bills on bytes scanned from S3, rounded up to 10 MB per query at a rate near 5 USD per terabyte. Spatial functions run in the compute layer for free, so the cost is entirely a function of how many Parquet row groups Athena reads. Partition pruning and column projection are the two levers that lower the bill; the ST_ predicate itself does not reduce bytes scanned because it runs after decompression.

Why does partition projection matter for spatial tables?

Partition projection lets Athena calculate which S3 prefixes to read directly from the query predicate, avoiding both slow Glue partition listing and MSCK REPAIR TABLE on tables with millions of partitions. For spatial datasets partitioned by tile or admin region, projection turns a metadata-bound query into one that reads only the relevant prefixes, cutting first-byte latency and keeping bytes scanned proportional to the queried extent.

Should I use CTAS to reorganise GeoParquet for Athena?

Yes, when repeat queries dominate. A CREATE TABLE AS SELECT pass lets you rewrite raw exports into ZSTD-compressed, spatially sorted files with right-sized row groups and partition columns tuned to your access pattern. The one-time scan cost is recovered quickly because every subsequent query scans far fewer bytes. CTAS also bucket-limits output file count, avoiding the small-file overhead that inflates request costs.


← Back to Query Engines & Cloud Analytics for GeoParquet

Continue exploring