GeoParquet Lake Layout and Bucket Policy Design
GeoParquet turns a vector dataset into something a query engine can read selectively: columnar, compressed, with per-row-group statistics that let an engine skip whole blocks it can prove are irrelevant. That skipping only happens if the layout cooperates. A billion-row parcel dataset written as one enormous file, or as ten thousand tiny ones, or partitioned by a column nobody filters on, produces exactly the same query cost as a naive scan while looking like a modern data lake. The layout is the performance work, and the bucket policy is what keeps a lake shared across teams from becoming a lake nobody trusts. This guide extends Object Storage for Raster and Vector Data within Geospatial Resource Provisioning.
Partitioning a spatial lake
Two partition dimensions matter for geospatial data and they compose.
Time. Nearly every analytical query on an operational dataset filters by date, so year=/month= prefixes are almost always right. They are also what makes lifecycle rules and retention tractable, because a prefix maps to an age.
Space. A spatial filter is the other half of nearly every query, and the way to make it prunable is a coarse spatial key in the path. Options are an administrative code, a UTM zone, or a low-zoom tile identifier such as a quadkey at zoom 5 or 6. The quadkey is the most general because it is uniform, hierarchical and computable from any geometry without a lookup table.
The rule for depth is that a partition column must be one that queries actually filter on, and must not produce partitions so small that per-file overhead dominates. Target row groups of tens of megabytes and files of a few hundred megabytes. Below that the engine spends its time opening files; above it, row-group skipping stops helping because each group covers too much.
Crucially, partitioning does not remove the need for a bounding-box column. Write bbox — minimum and maximum coordinates as plain numeric columns — alongside the geometry, because engines can compute statistics on numeric columns and use them to skip row groups, whereas a serialised geometry column is opaque to that optimisation. The partition prunes at the file level; the bbox statistics prune at the row-group level; both are needed.
Prerequisites and environment assumptions
Terraform 1.6 or later with hashicorp/aws pinned at ~> 5.60. A writer that emits GeoParquet with the metadata the specification requires — the geo key in the file metadata carrying the CRS and geometry column name — because a Parquet file with a geometry column and no geo metadata is not GeoParquet and will be read as opaque binary by conforming readers. A catalogue if more than one team queries the lake; a table registered in a metastore is what turns a prefix convention into something discoverable.
Fix the coordinate reference system for the lake before writing anything. Mixed CRS across partitions is the defect that is hardest to unwind, because a query engine will happily union files whose coordinates mean different things and return results that are wrong rather than erroneous. One CRS per table, recorded in the file metadata, enforced by the writer.
Step-by-step layout and policy
-
Choose the partition keys and write them into the path. Date first, spatial key second, in Hive-style
key=valuedirectories so engines discover them automatically. Keep the spatial key coarse: a zoom-5 quadkey gives roughly a thousand cells globally, which is a sensible number of partitions, whereas zoom 12 gives sixteen million and destroys the layout. -
Add the bbox columns at write time. Four numeric columns —
bbox_xmin,bbox_ymin,bbox_xmax,bbox_ymax— derived from each geometry. They cost a little storage and they are what makes row-group skipping work. -
Compact on a schedule. Streaming or incremental writers produce many small files, and a lake degrades continuously without compaction. A daily job that rewrites yesterday’s partitions into properly sized files is not optional maintenance; it is part of the design.
-
Separate write and read identities in the bucket policy. Exactly one writer identity per table prefix, and read-only access for everyone else. A lake where any consumer can write is a lake where a well-meaning notebook overwrites a partition and nobody can say when.
-
Deny unencrypted transport and enforce the key. Standard for any bucket holding regulated geometry, and consistent with the domain-key approach in Encryption and Key Management for Spatial Data.
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.60" }
}
}
variable "writer_role_arn" { type = string }
variable "reader_role_arns" { type = list(string) }
data "aws_iam_policy_document" "lake" {
# Exactly one writer per table prefix. A lake anyone can write to is a lake
# where a notebook overwrites a partition and nobody can say when.
statement {
sid = "SingleWriter"
effect = "Allow"
actions = ["s3:PutObject", "s3:DeleteObject"]
resources = ["${aws_s3_bucket.lake.arn}/tables/parcels/*"]
principals {
type = "AWS"
identifiers = [var.writer_role_arn]
}
}
statement {
sid = "BroadRead"
effect = "Allow"
actions = ["s3:GetObject", "s3:ListBucket"]
resources = [aws_s3_bucket.lake.arn, "${aws_s3_bucket.lake.arn}/*"]
principals {
type = "AWS"
identifiers = var.reader_role_arns
}
}
statement {
sid = "DenyPlaintextTransport"
effect = "Deny"
actions = ["s3:*"]
resources = [aws_s3_bucket.lake.arn, "${aws_s3_bucket.lake.arn}/*"]
principals {
type = "*"
identifiers = ["*"]
}
condition {
test = "Bool"
variable = "aws:SecureTransport"
values = ["false"]
}
}
}
resource "aws_s3_bucket_lifecycle_configuration" "lake" {
bucket = aws_s3_bucket.lake.id
# Scoped by prefix so it can never touch the current partitions a query
# engine reads continuously — an unscoped rule here is the classic mistake.
rule {
id = "archive-cold-partitions"
status = "Enabled"
filter { prefix = "tables/parcels/year=" }
transition {
days = 365
storage_class = "STANDARD_IA"
}
}
# Compaction leaves behind superseded files; expiring them is what stops the
# lake growing faster than the data it holds.
rule {
id = "expire-compacted-remnants"
status = "Enabled"
filter { prefix = "tables/parcels/_staging/" }
expiration { days = 7 }
}
}
The path layout the policy above assumes, written out so the convention is unambiguous:
s3://gis-lake/tables/parcels/year=2026/month=07/qk=03201/part-0001.parquet
│ │ │ └── a few hundred MB per file
│ │ └────────── zoom-5 quadkey: ~1000 cells globally
│ └──────────────────── date partition: prunes and ages
└───────────────────────────── one writer identity owns this prefix
Verification
Run a representative query with the engine’s explain output and confirm partitions were pruned — the number of files scanned should be a small fraction of the total, and if it equals the total the partition keys do not match the filter. Then confirm row-group skipping by comparing bytes scanned against total file size for a spatially narrow query; a query over one city that scans the whole national file has bbox columns missing or unwritten.
Confirm the files are genuine GeoParquet by reading the geo metadata key and checking the declared CRS matches what you intended. Check file and row-group sizes across a few partitions against the targets. And test the policy: attempt a write as a reader identity and require it to fail, because a lake’s single-writer guarantee is worth exactly as much as the test that proves it.
Preventing recurrence
- Monitor file count per partition. A rising count is the earliest signal that compaction has stopped, and it precedes the query slowdown by days.
- Assert the CRS at write time. A writer that refuses to emit a file whose CRS differs from the table’s declared one prevents the defect that is hardest to unwind.
- Scope every lifecycle rule by prefix. An unscoped archival transition will eventually move a hot partition into a class with retrieval latency, which presents as an inexplicably slow query.
- Keep the catalogue and the layout in one module. A table registered in a metastore whose partition scheme has drifted from the actual prefixes produces queries that silently miss data.
Frequently Asked Questions
Do I need bbox columns if I already partition spatially?
Yes. The partition prunes at the file level and the bbox statistics prune at the row-group level, and a coarse partition still leaves a lot of data inside each file. Without numeric bbox columns the engine cannot skip anything inside a file, because a serialised geometry column carries no usable statistics.
What zoom level should the quadkey partition use?
Coarse — zoom 5 or 6, giving roughly a thousand to four thousand cells globally. The aim is enough partitions to prune meaningfully and few enough that each holds properly sized files. A fine quadkey produces millions of nearly empty partitions and is strictly worse than no spatial partition at all.
Should GeoParquet replace PostGIS?
They answer different questions. GeoParquet is for analytical scans over large historical volumes; PostGIS is for transactional access, indexed point queries and the live rendering path described in Vector Tile Service Provisioning. Most platforms run both, with the lake fed from the database.
How do I handle late-arriving data in a date partition?
Write it into the partition it belongs to and let the next compaction pass fold it in. Re-partitioning by arrival time to avoid rewriting is tempting and it breaks every query that filters on event date, which is all of them.
Related
- Object Storage for Raster and Vector Data — the parent topic on bucket layout for spatial assets
- Configuring S3 Lifecycle Rules for GIS Tiles — scoping lifecycle rules so they never reach hot prefixes
- Encryption and Key Management for Spatial Data — the key and transport policy this bucket enforces
- PostGIS Cluster Provisioning — the transactional store the lake is usually fed from