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

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 that is 134 MB of hash table before counting the strings, and as zstd-compressed Parquet it is still 10 MB and 48 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 100,394 points.

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))

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)
Bar chart of H3 compaction ratios at resolution 9 for Chad, Switzerland, the United Kingdom and Norway.
Compaction rewards large solid interiors. Chad's desert border compacts nearly ten times better than Norway's fjords.

Step-by-step solution

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.88 s
United Kingdom    2,602,735      16.88 s
Chad             10,997,496      29.17 s
Norway            4,952,042      58.18 s

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.202 s for the UK and 0.859 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, zstd Parquet      10.0 MB    read the file
compacted, zstd Parquet     297 KB      uncompact: 0.018 s
the polygon, WKB            115 KB      fill: 16.88 s

The polygon was smaller for every country: 12 KB against 34 KB for Switzerland, 19 KB against 135 KB for Chad. What a compacted set buys is the 16.88 s fill, reduced to an 18 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: 146,960 points took 0.29 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.

Table comparing storing all H3 cells, a compacted set and the source polygon by size and by time to recover the cells.
Store the polygon to save bytes, the compacted cells to save the fill, and all cells only when reads must be free.

Code examples

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
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
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)
146,960 points: CompactRegion 100,394 in 0.39s; full set 100,394 in 0.13s; per-res latlng shortcut 97,710

The loop checks at most seven resolutions per point, so it is about three times slower than a lookup in the full set and uses 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.

Explanation

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,710 of the 100,394 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.

Two panels contrasting a point lookup that indexes at each resolution with one that walks up from the finest cell using cell_to_parent.
The wrong lookup raises no error and returns a plausible count; only a comparison with the full set shows the gap.

Edge cases or notes

  • 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. h3_compact_cells(list(cell)) compacted the UK's 2.6 million cells to 60,925 in 0.13 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.

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 297 KB as Parquet. Compacted cells save the fill instead: uncompacting took 0.018 s where refilling from the polygon took 16.88 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,394 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.