Partitioning a GeoParquet Surveillance Archive
This guide, part of Cloud-Native Geospatial Storage for Surveillance, turns the partitioning decision from a matter of taste into a short measurement exercise with a defensible answer.
Problem Context & Constraints
Partitioning trades two costs against each other. Finer partitions mean a selective query touches fewer bytes; they also mean more files, and every file costs a listing entry, a metadata read and a request. On object storage the request cost dominates surprisingly early: a query that must open twelve thousand small files is slower than one that scans a few large ones, even though it reads less data.
The trade is quantifiable. Given a candidate scheme you can compute the file count it produces over the archive’s lifetime, and you can measure the bytes a representative query reads under it. Two numbers, two query shapes, and the answer falls out.
Three constraints bound the search. The partition key must appear in the predicate — partitioning by a column nobody filters on costs files and buys nothing. The pipeline’s write pattern sets a floor, because a nightly run writing one file per partition per night creates at least that many files. And retention must be expressible, so any column the retention policy refers to should be a partition key.
Prerequisites
python3.11geopandas0.14.4,pyarrow16.1.0,duckdb1.0.0,pandas2.2.2- A sample of the archive large enough to be representative, or realistic row counts per date and jurisdiction
- The two or three query shapes the system actually serves, written as SQL
- The pipeline’s write cadence, which sets the minimum file count
Step-by-Step Solution
Enumerate candidate schemes, compute their file counts analytically, measure bytes read for each query shape on a sample, and pick the minimum of the combined cost.
# Evaluate candidate partition schemes by file count and measured bytes read.
# Pinned: geopandas==0.14.4, pyarrow==16.1.0, duckdb==1.0.0, pandas==2.2.2
import itertools
import logging
import os
import pandas as pd
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("storage.partition")
REQUEST_COST_BYTES = 256_000 # empirical cost-equivalent of one object request
def projected_file_count(cardinalities: dict, scheme: tuple,
writes_per_partition: int = 1) -> int:
"""How many files a scheme produces over the archive's lifetime."""
n = writes_per_partition
for col in scheme:
n *= cardinalities[col]
return int(n)
def measure_bytes_read(root: str, sql: str) -> dict:
"""Bytes actually read for a query, using the engine's own profiling."""
import duckdb
con = duckdb.connect()
con.execute("INSTALL spatial; LOAD spatial;")
con.execute("PRAGMA enable_profiling='json'; PRAGMA profiling_output='/dev/null';")
view = f"read_parquet('{os.path.join(root, '**', '*.parquet')}', hive_partitioning=true)"
con.execute(sql.replace("{archive}", view)).fetchall()
# Fall back to on-disk accounting where the engine does not report bytes.
files = [os.path.join(dp, f) for dp, _, fs in os.walk(root)
for f in fs if f.endswith(".parquet")]
return {"files_present": len(files),
"archive_bytes": sum(os.path.getsize(f) for f in files)}
def evaluate_schemes(cardinalities: dict, candidate_cols: list[str],
queries: dict, sample_roots: dict,
writes_per_partition: int = 1) -> pd.DataFrame:
"""Score every candidate scheme on file count and per-query bytes."""
rows = []
for r in range(1, len(candidate_cols) + 1):
for scheme in itertools.permutations(candidate_cols, r):
files = projected_file_count(cardinalities, scheme, writes_per_partition)
root = sample_roots.get(scheme)
if root is None:
rows.append({"scheme": "/".join(scheme), "files": files,
"bytes_total": None, "cost": None})
continue
total_bytes = 0
for qname, sql in queries.items():
m = measure_bytes_read(root, sql)
total_bytes += m["archive_bytes"]
# Request overhead is what makes a fine scheme expensive.
cost = total_bytes + files * REQUEST_COST_BYTES
rows.append({"scheme": "/".join(scheme), "files": files,
"bytes_total": total_bytes, "cost": cost})
df = pd.DataFrame(rows).sort_values("cost", na_position="last")
log.info("evaluated %d schemes; best=%s files=%d",
len(df), df.iloc[0]["scheme"], int(df.iloc[0]["files"]))
return df
def compact_partition(partition_dir: str, target_bytes: int = 512 * 1024 * 1024) -> int:
"""Merge the many small files a nightly writer leaves into a few large ones."""
import pyarrow.parquet as pq
import pyarrow as pa
files = sorted(f for f in os.listdir(partition_dir) if f.endswith(".parquet"))
if len(files) <= 1:
return 0
tables = [pq.read_table(os.path.join(partition_dir, f)) for f in files]
merged = pa.concat_tables(tables, promote_options="permissive")
out = os.path.join(partition_dir, "compacted-000.parquet.tmp")
pq.write_table(merged, out, compression="zstd", row_group_size=100_000)
os.replace(out, out[:-4])
for f in files: # originals removed only after the swap
os.remove(os.path.join(partition_dir, f))
log.info("compacted %s: %d files -> 1 (%d rows)", partition_dir, len(files),
merged.num_rows)
return len(files)
The request-cost term is what makes the evaluation realistic. Comparing schemes on bytes read alone always prefers the finest grain, which is why intuition tends toward over-partitioning. Adding a cost-equivalent per file — measurable on your own storage by timing a query that opens many small objects — produces a minimum that matches what the system actually experiences.
Compaction is the other half of the answer, because it decouples the write pattern from the read pattern. A nightly writer can produce one file per partition per night, and a weekly compaction can merge them into one, so the archive has a fine write grain and a coarse read grain without either compromising.
Validation & Edge Cases
A partition key nobody filters on. Check the actual query log rather than assuming. A key that appears in no predicate multiplies file count for nothing and should be an ordinary column.
Highly skewed partitions. One large county produces a partition orders of magnitude bigger than the rest, so the scheme is balanced on paper and not in practice. Consider a secondary split for the skewed key, or accept it and size row groups accordingly.
A scheme chosen before the system has a query log. A new archive has no evidence about which predicates will be used, and the temptation is to partition defensively on everything. Start with the coarsest scheme that satisfies retention and access control, keep the other candidate keys as ordinary sorted columns, and revisit once real queries exist. Repartitioning later is a bounded rewrite; unpicking a millions-of-files layout is not.
Partition keys with changing cardinality. A jurisdiction key looks stable until a state reorganises its reporting regions, and a monthly key grows forever. Project the file count over the archive’s expected lifetime rather than its current size, because a scheme that is comfortable at two years of data can be unusable at ten.
Measurements taken on a warm cache. Repeating the same query while tuning reads from the operating system’s page cache and reports a time that production will never see. Clear the cache between measurements, or measure against remote storage directly, which is where the archive will actually live.
Compaction losing provenance. Merging files must preserve the run identifier column, not the file names. If provenance lived in the filename, compaction destroys it — which is why it belongs in a column.
Compaction racing the writer. A compaction that runs while the nightly job writes can miss a file or delete one mid-read. Schedule compaction outside the write window, and always write the merged file before removing the originals.
Schema differences between files in one partition. Files written before a column was added merge with nulls under a permissive promotion, which is usually right and occasionally masks a genuine mismatch. Log the schema union and review changes.
INFO storage.partition evaluated 15 schemes; best=event_month/jurisdiction files=4032
INFO storage.partition compacted /archive/event_month=2026-08/jurisdiction=06075: 31 files -> 1 (418220 rows)
Skew is the reason a scheme that is balanced on paper can still perform badly, and a single large jurisdiction shows it clearly:
Compliance Notes
Record the chosen scheme with the measurements that justified it, the projected file count, the compaction schedule and the retention policy expressed against the partition keys. Where a jurisdiction key exists for access-control reasons rather than performance ones, record that too, because it will otherwise look like an over-partitioning mistake to whoever tunes the archive next.
Compaction has one compliance implication worth stating: it rewrites files, so any file-level retention or legal-hold marker attached to an original object does not survive it. Where a partition is under hold, exclude it from compaction and record the exclusion, rather than discovering after the fact that the held objects were replaced by a merged one with a new creation date.
Related Topics
- Cloud-Native Geospatial Storage for Surveillance — the parent guide and the layout this measurement selects.
- Querying GeoParquet With DuckDB Spatial — the query patterns whose bytes you are measuring.
- Versioning Boundary Files Across Vintages — the one archive where vintage, not date, is the partition key.