Choosing an H3 Resolution for Point Data
This guide gives you a repeatable method — plus a runnable script that samples your dataset and prints a recommendation — for picking the H3 resolution that hits your target rows-per-partition while keeping your query radius inside a small ring of cells.
Context and prerequisites
H3 resolution is the knob that decides partition cardinality: each finer resolution splits a cell into seven children, so moving one level multiplies your partition count by roughly seven and divides rows-per-cell by the same factor. Pick it wrong and you either get multi-gigabyte hot partitions or millions of tiny files. This page assumes you have already chosen H3 over S2 and geohash — that decision is covered in selecting a discrete global grid for lakehouse partitioning and benchmarked in H3 vs S2 vs geohash for lakehouse partitioning. You need Python 3.10+ and h3-py 4.x.
python -m pip install "h3>=4.1,<5" "pandas>=2.0" "pyarrow>=14"
The method balances two constraints. First, a capacity constraint: at your target rows-per-partition, which resolution keeps the busiest cell near that target? Second, a query-radius constraint: your typical proximity query radius should be covered by a small H3 grid_disk k-ring, not hundreds of cells, or point lookups fan out across too many partitions.
Complete working solution
This script reads a Parquet sample of your points, evaluates every candidate resolution, and recommends the finest resolution whose 95th-percentile cell load stays at or below your target rows-per-partition while the query radius fits within a k-ring of at most max_k. It runs standalone against any Parquet file with lat/lon columns.
import argparse
import math
import pandas as pd
import pyarrow.parquet as pq
import h3
# average H3 edge length in meters, by resolution (h3geo.org table, res 0-12)
H3_EDGE_M = {
0: 1_281_256, 1: 483_057, 2: 182_513, 3: 68_979, 4: 26_072,
5: 9_854, 6: 3_724, 7: 1_406, 8: 531, 9: 200,
10: 75.9, 11: 28.7, 12: 10.8,
}
def load_sample(path: str, sample_rows: int) -> pd.DataFrame:
df = pq.read_table(path, columns=["lat", "lon"]).to_pandas()
df = df.dropna(subset=["lat", "lon"])
if len(df) > sample_rows:
df = df.sample(sample_rows, random_state=42)
return df
def cells_to_cover_radius(res: int, radius_m: float) -> int:
# k such that a k-ring of res-cells spans the query radius
edge = H3_EDGE_M[res]
return max(1, math.ceil(radius_m / (edge * math.sqrt(3))))
def evaluate(df: pd.DataFrame, target_rpp: int, radius_m: float,
max_k: int, scale_factor: float,
res_range=range(4, 13)) -> pd.DataFrame:
rows = []
for res in res_range:
cells = df.apply(lambda r: h3.latlng_to_cell(r.lat, r.lon, res), axis=1)
load = cells.value_counts()
# scale sample counts up to the full dataset
p95 = float(load.quantile(0.95)) * scale_factor
peak = int(load.max()) * scale_factor
k = cells_to_cover_radius(res, radius_m)
rows.append({
"res": res,
"distinct_cells_in_sample": load.size,
"p95_rows_per_cell": round(p95, 1),
"peak_rows_per_cell": round(peak, 1),
"k_ring_for_radius": k,
"meets_capacity": p95 <= target_rpp,
"meets_radius": k <= max_k,
})
return pd.DataFrame(rows)
def recommend(report: pd.DataFrame) -> int | None:
ok = report[report.meets_capacity & report.meets_radius]
# finest resolution that satisfies both constraints
return int(ok.res.max()) if not ok.empty else None
def main():
ap = argparse.ArgumentParser()
ap.add_argument("parquet")
ap.add_argument("--total-rows", type=int, required=True,
help="row count of the FULL dataset")
ap.add_argument("--sample-rows", type=int, default=200_000)
ap.add_argument("--target-rpp", type=int, default=5_000_000,
help="target rows per partition after write")
ap.add_argument("--query-radius-m", type=float, default=1_000.0)
ap.add_argument("--max-k", type=int, default=3)
args = ap.parse_args()
df = load_sample(args.parquet, args.sample_rows)
scale = args.total_rows / len(df)
report = evaluate(df, args.target_rpp, args.query_radius_m, args.max_k, scale)
print(report.to_string(index=False))
rec = recommend(report)
if rec is None:
print("\nNo resolution meets both constraints; relax target-rpp or max-k.")
else:
print(f"\nRecommended H3 resolution: {rec}")
if __name__ == "__main__":
main()
Invoke it against a Parquet extract of your table:
python h3_resolution_picker.py points.parquet \
--total-rows 4200000000 \
--target-rpp 5000000 \
--query-radius-m 800 \
--max-k 3
Step-by-step walkthrough
load_samplereads onlylat/lonand subsamples to keep the scan cheap. A 200k-row sample estimates cell load distribution well enough to choose a resolution; you do not need the full 4-billion-row table.scale_factor(total_rows / sample_rows) projects the sampled per-cell counts up to the full dataset, sop95_rows_per_cellis an estimate of the real partition load, not the sample load.evaluateencodes the sample at each candidate resolution, then computes the 95th-percentile and peak cell load. It uses the 95th percentile rather than the mean because partition sizing must survive the busy cells, not the average one.cells_to_cover_radiusconverts your query radius into a k-ring count using H3’s average edge length per resolution. A finer resolution needs a largerkto cover the same radius; whenkexceedsmax_k, proximity queries touch too many partitions.recommendreturns the finest resolution satisfying both the capacity and radius constraints — finest, because smaller cells give better predicate selectivity, and you want the smallest cell you can afford before partitions get too big or ring queries fan out.- If no resolution qualifies, the two constraints conflict: either your target rows-per-partition is too small for your query radius, or the data is too dense. Relax
--target-rppupward or--max-kand rerun.
The default --target-rpp of 5 million rows aligns with a 128 MB–1 GB Parquet file at typical point-row widths. Once you have the resolution, materialize it as a partition column and wrap it in a bucket transform to tame hot cells, exactly as shown for spatial partitioning schemes and implementing H3 hexagon partitioning in Delta Lake.
Common errors and fixes
| Error | Cause | Fix |
|---|---|---|
KeyError in H3_EDGE_M |
res_range extends past 12 |
Extend the edge table from the h3geo.org resolution page |
| p95 looks unrealistically low | Sample too small or not representative | Raise --sample-rows; sample across time, not one hour |
| Recommendation flips between runs | No fixed random seed on a fresh extract | Sampling is seeded (random_state=42); pin the Parquet extract |
meets_radius always False |
Query radius large vs. cell size | Raise --max-k or accept a coarser resolution |
| Peak cell dwarfs p95 | A single mega-cell (stadium, port) | Plan to bucket() that partition; do not chase it with resolution |
Verification
After ingesting at the recommended resolution, confirm the real partition load matches the estimate. This Spark SQL check reports the 95th-percentile and max rows per H3 cell on the written table; both should sit near your target.
-- Spark SQL on the Iceberg/Delta table, after ingest
SELECT
percentile_approx(cnt, 0.95) AS p95_rows_per_cell,
MAX(cnt) AS peak_rows_per_cell,
COUNT(*) AS distinct_cells
FROM (
SELECT h3_res8 AS cell, COUNT(*) AS cnt
FROM lake.geo.events
GROUP BY h3_res8
);
If peak_rows_per_cell runs far above p95, a few hot cells dominate — handle those with a bucket transform rather than by globally raising resolution, which would over-fragment every rural cell. Then confirm the key still prunes with predicate pushdown optimization, and pair the partition with intra-file Z-ordering for geospatial queries on lon, lat.
Authoritative references for the numbers above: the H3 resolution table and the broader H3 documentation.