How to Compact an H3 Cell Set to Store a Region Cheaply

On this page

Problem statement

Filling a region with H3 cells at a useful resolution produces a lot of cells. The United Kingdom at resolution 9 is 2,602,735 of them; Chad is 10,997,496. As a Python set the UK is 134 MB of hash table before counting the strings, and as sorted, zstd-compressed Parquet the two are still 2.9 MB and 11.7 MB.

Most of those cells are in the interior, where every child of a parent is present. compact_cells replaces each complete family with its parent, repeatedly, until only the ragged edge stays at the fine resolution. Measured, it shrank the UK to 60,925 cells (42.7Γ—) and Chad to 28,650 (383.9Γ—), and uncompacting reproduced the original sets exactly.

There are two things to know before relying on it. The compacted set is still bigger than the polygon it came from, so its real value is skipping the fill, not saving bytes. And a point-in-region test against it has to be written one particular way β€” the obvious alternative silently missed 2,684 of the 100,396 GeoNames points inside the UK.

Quick answer

import h3

cells = h3.geo_to_cells(uk_polygon, 9)          # 2,602,735 cells
compacted = h3.compact_cells(cells)             # 60,925 cells at resolutions 3–9
restored = h3.uncompact_cells(compacted, 9)     # the original 2,602,735

print(len(cells), len(compacted), set(restored) == set(cells))
2602735 60925 True

To test whether a point is inside the compacted region, index it at the finest resolution present and walk up with cell_to_parent:

finest = max(h3.get_resolution(c) for c in compacted)
cell = h3.latlng_to_cell(lat, lng, finest)
inside = any(h3.cell_to_parent(cell, r) in compacted_set for r in resolutions)
H3 compaction ratios at resolution 9 for Chad, Switzerland, the United Kingdom and Norway. resolution-9 fill, compactedChad383.9Γ— Β· 11.0 M β†’ 28,650Switzerland56.2Γ— Β· 407,632 β†’ 7,252United Kingdom42.7Γ— Β· 2.60 M β†’ 60,925Norway30.3Γ— Β· 4.95 M β†’ 163,2320400Every uncompact restored the original set exactly spatialworkflow.io
Compaction rewards large solid interiors. Chad's desert border compacts nearly ten times better than Norway's fjords.

Fill, compact, deduplicate, store with the resolution

1. Fill the region at the resolution you need

geo_to_cells accepts a shapely geometry or GeoJSON-like mapping in longitude, latitude order. The fill is the slow part, and it grows with both resolution and boundary complexity:

country          res 9 cells    fill time
Switzerland         407,632       0.61 s
United Kingdom    2,602,735      14.19 s
Chad             10,997,496      23.25 s
Norway            4,952,042      51.91 s

(Example 4 measures these.) Norway has fewer cells than Chad and took twice as long, because its coastline polygon has far more vertices to test against.

2. Compact it

compacted = h3.compact_cells(cells)

The call took 0.091 s for the UK and 0.629 s for Chad. The result mixes resolutions. For the UK it held 3 cells at resolution 3, 49 at 4, 293 at 5, 1,069 at 6, 4,406 at 7, 14,181 at 8 and 40,924 at 9 β€” two thirds of the compacted set is still the fine edge.

3. Expect the ratio to depend on shape and resolution

Every extra resolution multiplies the interior by seven and the edge by only about 2.6, so the ratio climbs with resolution:

country          res 6    res 8    res 9
Switzerland       4.1Γ—    21.7Γ—    56.2Γ—
United Kingdom    4.4Γ—    17.9Γ—    42.7Γ—
Norway            3.3Γ—    12.7Γ—    30.3Γ—
Chad             22.8Γ—   148.8Γ—   383.9Γ—

A long, crinkled coastline keeps many cells at the finest resolution. A large, simple land border lets whole resolution-2 and resolution-3 cells stand in for millions of children.

4. Deduplicate before compacting

compact_cells does not always detect duplicates. Measured on h3 4.5:

10 scattered cells, each passed twice     no error, 20 cells returned
7 siblings plus one repeated              H3DuplicateInputError
7 siblings, each passed twice             H3DuplicateInputError

The first case is the dangerous one: duplicates survive into the output and inflate any count built on it. Pass set(cells) and both failures disappear.

5. Store it with its fill resolution

The compacted cells alone do not say which resolution to uncompact to. Record it next to the data β€” Example 2 writes it into the Parquet schema metadata. Asking for a coarser resolution than a cell in the set raises H3ResMismatchError, so a wrong value fails loudly rather than returning something plausible.

6. Compare against simply storing the polygon

Measured for the United Kingdom at resolution 9:

stored as                            size       to get the cells back
all cells, sorted, zstd Parquet      2.87 MB    read the file
compacted, sorted, zstd Parquet      183 KB     uncompact: 0.010 s
the polygon, WKB                     115 KB     fill: 14.19 s

The polygon was smaller for every country: 12 KB against 21 KB for Switzerland, 19 KB against 87 KB for Chad, 255 KB against 435 KB for Norway. What a compacted set buys is the 14.19 s fill, reduced to a 10 ms uncompact, and a region definition that is already in cell space for joins.

7. Test membership without uncompacting

Uncompacting the UK back to 2.6 million cells to test a handful of points wastes the saving. Test each point against the compacted set directly: 140,074 GeoNames points took 0.24 s against the compacted set and 0.10 s against the full one, with identical answers of 100,394 inside. The set itself used 2.1 MB instead of 134 MB.

Storing all H3 cells, a compacted set or the source polygon, compared by size and time to recover the cells. the United Kingdom at resolution 9stored assizecells back inall cells (sorted Parquet)2.87 MBa readcompacted (sorted Parquet)183 KB0.010 spolygon (WKB)115 KB14.19 s fillthe polygon was the smallest file for all four countriesCompacted cells trade a little size for skipping the fill spatialworkflow.io
Store the polygon to save bytes, the compacted cells to save the fill, and all cells only when reads must be free.

Compacting countries and testing points against them

The data used on this page

Country outlines from Natural Earth's 1:10m admin-0 layer, and, as test points, every GeoNames feature inside the United Kingdom's bounding box β€” GeoNames is edited daily, so the count drifts.

import os
import zipfile

import geopandas as gpd
import pandas as pd
import requests


def fetch(url, path):
    if not os.path.exists(path):
        with requests.get(url, stream=True, timeout=600) as reply:
            reply.raise_for_status()
            with open(path, "wb") as out:
                for chunk in reply.iter_content(1 << 20):
                    out.write(chunk)


fetch("https://naciscdn.org/naturalearth/10m/cultural/ne_10m_admin_0_countries.zip",
      "ne_10m_admin_0_countries.zip")
world = gpd.read_file("ne_10m_admin_0_countries.zip")
country = {name: world.loc[world["ADMIN"] == name, "geometry"].iloc[0]
           for name in ("Switzerland", "United Kingdom", "Chad", "Norway")}
uk_polygon = country["United Kingdom"]

if not os.path.exists("geonames_all.parquet"):
    fetch("https://download.geonames.org/export/dump/allCountries.zip", "allCountries.zip")
    with zipfile.ZipFile("allCountries.zip") as z, z.open("allCountries.txt") as f:
        pd.read_csv(f, sep="\t", header=None, usecols=[4, 5], names=["lat", "lon"],
                    quoting=3).to_parquet("geonames_all.parquet")
x0, y0, x1, y1 = uk_polygon.bounds
points = pd.read_parquet("geonames_all.parquet", columns=["lat", "lon"])
points = points[points.lon.between(x0, x1) & points.lat.between(y0, y1)].reset_index(drop=True)
print(len(points), "GeoNames points in the UK's bounding box")
140074 GeoNames points in the UK's bounding box

Example 1 β€” fill, compact and report

import numpy as np
from h3.api import numpy_int as h3n


def compact_region(geometry, res):
    """Fill a shapely geometry at res, compact it, and report the saving."""
    cells = np.asarray(h3n.geo_to_cells(geometry, res), dtype=np.uint64)
    compacted = np.asarray(h3n.compact_cells(cells), dtype=np.uint64)
    by_res = np.bincount([h3n.get_resolution(int(c)) for c in compacted], minlength=res + 1)
    print(f"res {res}: {len(cells):,} cells -> {len(compacted):,} compacted "
          f"({len(cells) / len(compacted):.1f}x), {cells.nbytes:,} -> {compacted.nbytes:,} bytes; "
          f"by resolution { {r: int(n) for r, n in enumerate(by_res) if n} }")
    return compacted


swiss = compact_region(country["Switzerland"], 9)
uk = compact_region(uk_polygon, 9)
res 9: 407,632 cells -> 7,252 compacted (56.2x), 3,261,056 -> 58,016 bytes; by resolution {4: 11, 5: 48, 6: 170, 7: 658, 8: 1765, 9: 4600}
res 9: 2,602,735 cells -> 60,925 compacted (42.7x), 20,821,880 -> 487,400 bytes; by resolution {3: 3, 4: 49, 5: 293, 6: 1069, 7: 4406, 8: 14181, 9: 40924}

The first line is Switzerland, the second the United Kingdom. The numpy_int API returns 64-bit integers, which are 8 bytes each and avoid building millions of Python strings. The geometry must be in EPSG:4326.

Example 2 β€” save and load with the resolution attached

import os

import numpy as np
import pyarrow as pa
import pyarrow.parquet as pq
from h3.api import numpy_int as h3n


def save_region(path, compacted, res):
    table = pa.table({"cell": pa.array(compacted, type=pa.uint64())})
    table = table.replace_schema_metadata({"h3_fill_resolution": str(res)})
    pq.write_table(table, path, compression="zstd")
    return os.path.getsize(path)


def load_region(path, uncompact=True):
    table = pq.read_table(path)
    res = int(table.schema.metadata[b"h3_fill_resolution"])
    cells = table.column("cell").to_numpy()
    if uncompact:
        cells = np.asarray(h3n.uncompact_cells(cells, res), dtype=np.uint64)
    return cells, res


size = save_region("switzerland_res9.parquet", swiss, 9)
restored, res = load_region("switzerland_res9.parquet")
full = np.asarray(h3n.geo_to_cells(country["Switzerland"], 9), dtype=np.uint64)
print("saved bytes", size, "round trip equal", np.array_equal(np.sort(restored), np.sort(full)), "res", res)
print("raw compacted rows", len(load_region("switzerland_res9.parquet", uncompact=False)[0]))
saved bytes 34148 round trip equal True res 9
raw compacted rows 7252

Switzerland's region file is 34 KB and restores to the same 407,632 cells. Metadata keys and values in Parquet are bytes, hence b"h3_fill_resolution" on the way back.

Example 3 β€” point-in-region against a compacted set

import h3


class CompactRegion:
    """Point-in-region tests against a compacted cell set, without uncompacting."""

    def __init__(self, compacted):
        self.cells = set(compacted)
        self.resolutions = sorted({h3.get_resolution(c) for c in self.cells})
        self.finest = self.resolutions[-1]

    def contains(self, lat, lng):
        cell = h3.latlng_to_cell(lat, lng, self.finest)
        return any(h3.cell_to_parent(cell, r) in self.cells for r in self.resolutions)


import time

region = CompactRegion(h3.compact_cells(h3.geo_to_cells(uk_polygon, 9)))
full_set = set(h3.geo_to_cells(uk_polygon, 9))
lats, lngs = points["lat"].to_numpy(), points["lon"].to_numpy()

start = time.perf_counter()
n_compact = sum(region.contains(a, b) for a, b in zip(lats, lngs))
t_compact = time.perf_counter() - start
start = time.perf_counter()
n_full = sum(h3.latlng_to_cell(a, b, 9) in full_set for a, b in zip(lats, lngs))
t_full = time.perf_counter() - start
n_shortcut = sum(any(h3.latlng_to_cell(a, b, r) in region.cells for r in region.resolutions)
                 for a, b in zip(lats, lngs))
print(f"{len(points):,} points: CompactRegion {n_compact:,} in {t_compact:.2f}s; "
      f"full set {n_full:,} in {t_full:.2f}s; per-res latlng shortcut {n_shortcut:,}")
140,074 points: CompactRegion 100,396 in 0.24s; full set 100,396 in 0.10s; per-res latlng shortcut 97,712

The loop checks at most seven resolutions per point, so it was about two and a half times slower than a lookup in the full set and used a sixtieth of the memory. The last figure is the version that calls latlng_to_cell at each resolution instead β€” the next section explains why it is wrong.

Example 4 β€” the measurements behind the steps

import io
import time

import duckdb
import pyarrow as pa
import pyarrow.parquet as pq

rows = []
for name, geom in country.items():
    start = time.perf_counter()
    cells = h3n.geo_to_cells(geom, 9)
    fill = time.perf_counter() - start
    start = time.perf_counter()
    comp = h3n.compact_cells(cells)
    compact_time = time.perf_counter() - start
    ratios = {res: len(h3n.geo_to_cells(geom, res)) / len(h3n.compact_cells(h3n.geo_to_cells(geom, res)))
              for res in (6, 8)}
    res9 = sum(h3n.get_resolution(int(c)) == 9 for c in comp)

    def parquet_bytes(values):
        buffer = io.BytesIO()
        pq.write_table(pa.table({"cell": pa.array(np.sort(values), type=pa.uint64())}), buffer,
                       compression="zstd")
        return buffer.tell()

    start = time.perf_counter()
    h3n.uncompact_cells(comp, 9)
    uncompact = time.perf_counter() - start
    rows.append((name, len(cells), fill, compact_time, ratios[6], ratios[8], len(cells) / len(comp),
                 len(comp), res9, parquet_bytes(cells), parquet_bytes(comp), len(geom.wkb), uncompact))

print(f"{'country':15} {'res 9 cells':>11} {'fill':>7} {'compact':>8} {'res 6':>6} {'res 8':>6} "
      f"{'res 9':>6} {'kept':>7} {'at 9':>7} {'all, zstd':>10} {'compacted':>10} {'WKB':>8} {'uncompact':>9}")
for r in rows:
    print(f"{r[0]:15} {r[1]:11,} {r[2]:6.2f}s {r[3]:7.3f}s {r[4]:5.1f}x {r[5]:5.1f}x {r[6]:5.1f}x "
          f"{r[7]:7,} {r[8]:7,} {r[9] / 1e6:8.2f} MB {r[10] / 1e3:7.0f} KB {r[11] / 1e3:5.0f} KB {r[12]:8.3f}s")

# duplicates
siblings = h3.cell_to_children(h3.latlng_to_cell(51.5, -0.12, 8), 9)
scattered = [h3.latlng_to_cell(51.5 + i, -0.12, 9) for i in range(10)]
for label, cells in [("10 scattered cells, each passed twice", scattered * 2),
                     ("7 siblings plus one repeated", siblings + siblings[:1]),
                     ("7 siblings, each passed twice", siblings * 2)]:
    try:
        print(f"{label:40} no error, {len(h3.compact_cells(cells))} cells returned")
    except Exception as err:
        print(f"{label:40} {type(err).__name__}")
try:
    h3.uncompact_cells(h3.compact_cells(siblings), 7)
except Exception as err:
    print("uncompact to a coarser resolution:", type(err).__name__)
print("six res-9 siblings plus a res-8 cell ->",
      len(h3.compact_cells(siblings[:6] + [h3.latlng_to_cell(40.0, -3.7, 8)])), "cells")

con = duckdb.connect()
con.execute("INSTALL h3 FROM community; LOAD h3;")
uk_cells = pa.table({"cell": pa.array(h3n.geo_to_cells(uk_polygon, 9), type=pa.uint64())})
start = time.perf_counter()
n = con.execute("SELECT len(h3_compact_cells(list(cell))) FROM uk_cells").fetchone()[0]
print(f"DuckDB h3_compact_cells: {n:,} cells in {time.perf_counter() - start:.2f}s")
country         res 9 cells    fill  compact  res 6  res 8  res 9    kept    at 9  all, zstd  compacted      WKB uncompact
Switzerland         407,632   0.61s   0.015s   4.1x  21.7x  56.2x   7,252   4,600     0.59 MB      21 KB    12 KB    0.001s
United Kingdom    2,602,735  14.08s   0.078s   4.4x  17.9x  42.7x  60,925  40,924     2.87 MB     183 KB   115 KB    0.009s
Chad             10,997,496  23.17s   0.617s  22.8x 148.8x 383.9x  28,650  18,073    11.66 MB      87 KB    19 KB    0.040s
Norway            4,952,042  51.80s   0.195s   3.3x  12.7x  30.3x 163,232 108,896     5.28 MB     435 KB   255 KB    0.021s
10 scattered cells, each passed twice    no error, 20 cells returned
7 siblings plus one repeated             H3DuplicateInputError
7 siblings, each passed twice            H3DuplicateInputError
uncompact to a coarser resolution: H3ResMismatchError
six res-9 siblings plus a res-8 cell -> 7 cells
DuckDB h3_compact_cells: 60,925 cells in 0.12s

Exact compaction, and why coastlines resist it

Why compaction is exact

A compacted parent does not mean "the area of the parent". It means "all seven children of this parent, and all of their descendants". uncompact_cells expands that back through the hierarchy, not through geometry, so the round trip returned the identical set for every country and resolution tested.

This matters because H3 children do not fit exactly inside their parent's outline. If compaction were geometric, every parent would add or lose slivers of territory. Because it is hierarchical, it adds and loses nothing.

Why the shortcut lookup misses points

The tempting membership test is to index the point at each resolution present in the set and look for any hit. But a point's resolution-6 cell is the hexagon containing it at resolution 6, which is not always the resolution-6 ancestor of its resolution-9 cell: near a parent's edge, the child sticks out into the neighbouring parent's territory.

The compacted set is built from ancestors, so the test must follow ancestors: index at the finest resolution, then cell_to_parent. Measured on the UK, the shortcut found 97,712 of the 100,396 points that the full set contains β€” a 2.7% loss, concentrated exactly where compacted parents meet.

Why coastlines resist compaction

Only complete families compact. Every cell that touches the boundary has at least one missing sibling, so it stays at the fine resolution, and so does every family above it that contains it.

The number of edge cells grows with the boundary's length measured at the cell size. Norway's coast is enormously long at resolution 9, so 108,896 of its 163,232 compacted cells are still resolution 9. Chad's border is short and straight by comparison, and 18,073 resolution-9 cells complete its outline.

Why it is smaller than the cells but not the polygon

A polygon describes the boundary with vertices and implies the interior. A compacted set describes the interior with cells and represents the boundary as tens of thousands of fine hexagons. The polygon will always be the more compact description of a shape. The cells are the more useful one when the next operation is a join or a group-by on cell ids, because they are already in that form.

A point lookup that indexes at each resolution against one that walks up from the finest cell with cell_to_parent. 140,074 GeoNames points tested against the compacted UKlatlng_to_cell at each resolutionβœ•97,712 points found insideβœ•2,684 missed, silentlyβœ•a coarse cell is not an ancestorβœ•misses gather at parent edgesfinest cell, then cell_to_parentβœ“100,396 β€” same as the full setβœ“0.24 s against 0.10 sβœ“set of 2.1 MB against 134 MBβœ“exact by constructionCompaction follows the hierarchy, so the lookup must too spatialworkflow.io
The wrong lookup raises no error and returns a plausible count; only a comparison with the full set shows the gap.

Sets versus lists and mixed resolutions

  • Pass a set, not a list. Duplicates either raise H3DuplicateInputError or pass through silently, depending on whether they complete a family.
  • Mixed-resolution input is accepted. compact_cells returned seven cells for six resolution-9 cells plus an unrelated resolution-8 cell, so check that the input really is one resolution.
  • Uncompact to the fill resolution or finer. A coarser target raises H3ResMismatchError.
  • DuckDB can do it in SQL. With the community h3 extension, h3_compact_cells(list(cell)) compacted the UK's 2.6 million cells to 60,925 in 0.12 s.
  • Compacted cells are not equal-area. Never count or map them directly; uncompact or weight by cell_area first.
  • The fill decides the edge, not the compaction. Centre containment leaves out slivers of coast; see the polygon-fill fixes before trusting a region.
  • Sort compacted integers before writing. Parents and their neighbourhoods then compress together: Switzerland's compacted file was 34 KB unsorted (Example 2) and 21 KB sorted (Example 4).

FAQ

What does compact_cells do?

It replaces every complete set of seven sibling cells with their parent, repeating up the hierarchy. The United Kingdom at resolution 9 went from 2,602,735 cells to 60,925, and uncompact_cells restored the exact original set.

How much smaller does a compacted set get?

It depends on the shape and resolution. At resolution 9, Norway compacted 30.3 times, the United Kingdom 42.7 times and Chad 383.9 times, because long crinkled boundaries keep many fine cells.

Is a compacted cell set smaller than the polygon?

No. The UK polygon as WKB was 115 KB and the compacted cells 183 KB as sorted Parquet. Compacted cells save the fill instead: uncompacting took 0.010 s where refilling from the polygon took 14.19 s.

How do I check whether a point is inside a compacted region?

Index the point at the finest resolution in the set, then test that cell and each of its parents with cell_to_parent. Indexing the point separately at each resolution missed 2,684 of 100,396 points.

Why does compact_cells raise H3DuplicateInputError?

The input contains a repeated cell inside a family that could be compacted. Scattered duplicates are not always detected and may pass through, so always compact a set rather than a list.

Can I map a compacted set?

Not as a density or count map, because its cells range over several resolutions and very different areas. Uncompact it, or weight each cell by its area, before drawing values.