Partitioning a GeoParquet OSM Lake by H3 Cell Jump to heading
Lay out a multi-gigabyte GeoParquet dataset so a query over one city reads a few megabytes instead of the whole thing — without shattering it into a million files nobody can list.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
Partitioning is directory-level filtering: the key becomes part of the path, and a reader that can evaluate a predicate against the key skips whole files without opening them. It is coarse, free at query time, and completely separate from the row-group statistics that filter within a file.
The only real decision is granularity.
Both ends of that dial are bad in different ways. Too few partitions and a query reads gigabytes to return kilobytes. Too many and the query spends longer listing and opening files than reading them — a Parquet file carries a metadata footer that must be read before any row can be returned, so a thousand tiny files means a thousand footer reads.
For OSM specifically, a fixed H3 resolution is usually the right answer despite giving uneven file sizes, because the alternative — adaptive splitting — requires every reader to consult a manifest to know which resolution applies where, and that manifest becomes a piece of infrastructure you have to keep correct.
Runnable solution Jump to heading
#!/usr/bin/env python3
"""Write an OSM feature stream as an H3-partitioned GeoParquet dataset."""
from __future__ import annotations
import json
import logging
from collections import defaultdict
from pathlib import Path
from typing import Iterable
import h3
import pyarrow as pa
import pyarrow.parquet as pq
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
logger = logging.getLogger(__name__)
PARTITION_RES = 4 # directory granularity — one global choice
SORT_RES = 7 # in-file sort key, finer than the partition
ROW_GROUP_ROWS = 200_000
TARGET_PARTITION_BYTES = 128 * 1024 * 1024
def partition_key(lat: float, lon: float) -> str:
return h3.latlng_to_cell(lat, lon, PARTITION_RES)
def sort_key(lat: float, lon: float) -> str:
return h3.latlng_to_cell(lat, lon, SORT_RES)
def geo_metadata(bbox: list[float]) -> bytes:
return json.dumps({
"version": "1.1.0",
"primary_column": "geometry",
"columns": {"geometry": {"encoding": "WKB", "geometry_types": [],
"crs": None, "bbox": bbox}},
}).encode()
def write_partitioned(batches: Iterable[pa.Table], root: Path) -> dict[str, int]:
"""Route each row to its partition file, sorting within the partition on close.
Rows arrive in whatever order the parser produced. Buffering per partition and
flushing when a buffer is large enough keeps memory bounded while still letting
each written chunk be sorted, which is what makes row-group statistics useful.
"""
root.mkdir(parents=True, exist_ok=True)
buffers: dict[str, list[pa.Table]] = defaultdict(list)
buffered_rows: dict[str, int] = defaultdict(int)
written: dict[str, int] = defaultdict(int)
def flush(cell: str) -> None:
if not buffers[cell]:
return
table = pa.concat_tables(buffers[cell])
table = table.sort_by([("h3_sort", "ascending"), ("osm_id", "ascending")])
meta = dict(table.schema.metadata or {})
meta[b"geo"] = geo_metadata(list(h3.cell_to_boundary(cell)[0]) * 2)
table = table.replace_schema_metadata(meta)
out_dir = root / f"h3_r{PARTITION_RES}={cell}"
out_dir.mkdir(exist_ok=True)
part = out_dir / f"part-{written[cell]:05d}.parquet"
pq.write_table(table, part, compression="zstd", compression_level=3,
row_group_size=ROW_GROUP_ROWS, write_statistics=True)
written[cell] += 1
buffers[cell].clear()
buffered_rows[cell] = 0
for batch in batches:
for cell in set(batch.column("h3_part").to_pylist()):
mask = pa.compute.equal(batch.column("h3_part"), cell)
slice_ = batch.filter(mask)
buffers[cell].append(slice_)
buffered_rows[cell] += slice_.num_rows
if buffered_rows[cell] * 400 > TARGET_PARTITION_BYTES: # ~400 B/row estimate
flush(cell)
for cell in list(buffers):
flush(cell)
total_files = sum(written.values())
logger.info("wrote %d file(s) across %d partition(s)", total_files, len(written))
return dict(written)
Reading it back exercises the pruning:
import pyarrow.dataset as ds
import pyarrow.compute as pc
dataset = ds.dataset("lake/", format="parquet", partitioning="hive")
# Resolve the query bbox to the partition cells it touches, then filter on the key.
cells = h3.geo_to_cells({"type": "Polygon", "coordinates": [BBOX_RING]}, PARTITION_RES)
table = dataset.to_table(filter=pc.field(f"h3_r{PARTITION_RES}").isin(list(cells)))
Step-by-step walkthrough Jump to heading
write_partitioned buffers per partition rather than writing a file per batch. Writing immediately would produce one small file per partition per batch — the many-tiny-files failure, arrived at by accident. Buffering until a partition has roughly a target file’s worth of rows, then flushing, gives files in the intended size band regardless of the order rows arrive in.
The two H3 resolutions do different jobs and should not be the same number. PARTITION_RES sets the directory granularity and therefore how many files exist. SORT_RES is finer and only orders rows inside a file, which is what makes the per-row-group min/max useful — the mechanism covered in the parent topic, Exporting OSM to GeoParquet & PostGIS. Using one resolution for both means every row in a file shares the sort key and the statistics distinguish nothing.
Hive-style directory names — h3_r4=841f8d7ffffffff — are what let pyarrow.dataset expose the key as a queryable column. A directory named just 841f8d7ffffffff still partitions the data physically but the reader cannot filter on it without being told the schema.
Verification Jump to heading
Check the shape of the layout before checking the queries:
find lake -name '*.parquet' | wc -l
find lake -name '*.parquet' -printf '%s\n' | sort -n | awk '
{a[NR]=$1; s+=$1} END {printf "min %.1f MB median %.1f MB max %.1f MB total %.1f GB\n",
a[1]/1e6, a[int(NR/2)]/1e6, a[NR]/1e6, s/1e9}'
A healthy layout has a median in the tens of megabytes and a maximum under a few hundred. A minimum in the kilobytes is fine — those are sparse cells — but a median in the kilobytes means the partition resolution is too fine.
Then confirm the pruning is real rather than assumed, by comparing bytes read:
import pyarrow.dataset as ds
scanner = dataset.scanner(filter=pc.field("h3_r4").isin(["841f8d7ffffffff"]))
print(scanner.count_rows()) # rows returned
print(dataset.count_rows()) # rows in the whole lake
If the two numbers are close for a small-area filter, the filter is not being pushed down — usually because the partitioning scheme was not declared when the dataset was opened.
Common errors and fixes Jump to heading
| Symptom | Root cause | Fix |
|---|---|---|
| Every query reads the whole lake | partitioning="hive" not passed when opening |
Declare it, or use ds.partitioning() explicitly |
| Thousands of sub-megabyte files | Flushed per batch instead of per size | Buffer per partition to a target size |
| One partition is 4 GB | A dense city at a coarse resolution | Split that cell to children, or raise the global resolution |
| Reader cannot find the geometry | Metadata attached before the sort, then lost | Attach geo metadata to the table you actually write |
h3.latlng_to_cell raises on some rows |
Null or invalid geometry reached the keying step | Filter invalid geometry upstream |
| Partition column missing from results | Directory named without key=value |
Use Hive-style names |
Frequently Asked Questions Jump to heading
Which H3 resolution should I partition at?
Start from file size, not from geography. Estimate bytes per feature, multiply by the features in your densest region, and pick the coarsest resolution that keeps that region’s partition under a few hundred megabytes. For continental OSM feature layers that usually lands at resolution 3 or 4; for a single country, 4 or 5.
Should I partition by country instead?
Only if consumers overwhelmingly ask country-shaped questions and you can tolerate Germany and Liechtenstein being one partition each. Country partitioning cannot prune within a country, so a query for one city still reads the whole of Germany. A cell scheme costs readability and gains uniform behaviour everywhere.
Can I repartition without rewriting everything?
Not really — the partition key is the directory path, so changing it means moving every row. What you can do cheaply is split an over-large partition: read that one cell, re-key its rows to child cells, write those, and delete the parent. Readers that filter on the parent key need to understand both, which is the manifest problem adaptive layouts have.
Do empty ocean cells cost anything?
No, because they never get created — a cell with no features produces no directory. What does cost is a cell with three features, which produces a file whose metadata footer is larger than its data. Those are harmless individually and worth watching in aggregate: if most partitions are tiny, the resolution is wrong.
Living with the layout Jump to heading
A partition scheme is a long-lived decision, because changing it means rewriting every row. Two habits make that decision survivable.
Record the scheme alongside the data. A small manifest at the dataset root naming the partition key, its resolution, the sort key and the writer version costs nothing and answers the question every later reader has: what does this directory name mean, and can I rely on rows inside being sorted. Without it, the layout is discoverable only by inspection and the sort order is discoverable not at all.
Monitor the partition size distribution on every write. The layout that was right when the dataset was built drifts as the underlying data grows unevenly — a city that doubles its building coverage turns a well-sized partition into an outsized one, and nothing announces it. A single log line per run reporting the median and maximum partition size makes the drift visible while it is still cheap to fix by splitting one cell rather than by repartitioning everything.
Specification reference Jump to heading
Hive-style partitioning encodes each key as a
name=valuedirectory component.pyarrow.datasetdiscovers these when opened withpartitioning="hive"and exposes them as columns, allowing a filter on the key to eliminate files before any Parquet footer is read.
Related Jump to heading
- Exporting OSM to GeoParquet & PostGIS — the topic this layout belongs to.
- Writing OSM Features to GeoParquet with PyArrow — the writer this extends.
- Choosing H3 Resolution for OSM Point Aggregation — the same ladder, used for a different purpose.
- Spatial Index Selection: R-tree, H3 or Quadkey — why a cell scheme rather than a tree here.
- Memory-Efficient Chunk Processing — the batching the writer consumes.
Up one level: Exporting OSM to GeoParquet & PostGIS.