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)
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.
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.
Edge cases or notes
- Pass a set, not a list. Duplicates either raise
H3DuplicateInputErroror pass through silently, depending on whether they complete a family. - Mixed-resolution input is accepted.
compact_cellsreturned 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_areafirst. - 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.
Internal links
- H3 hierarchy explained: parents, children and why they do not nest exactly โ the reason the lookup must use cell_to_parent
- How to fill a polygon with H3 cells โ producing the set that gets compacted
- Fixing H3 polygon fill that misses cells or returns nothing โ what the fill leaves out at the edge
- Choosing an H3 resolution: cell size, counts and what each level can show โ why resolution 9 is so many cells
- How to use H3 in DuckDB for grid aggregation at scale โ compaction and joins in SQL
- How to join two point datasets on an H3 index instead of a spatial join โ the region as a join key
- S2 cells explained: squares on a cube and when they beat hexagons โ S2 coverings, the same idea with exact nesting
- How to reduce GIS file size in Python without wrecking the data โ other ways to shrink a deliverable
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.