How to Use H3 in DuckDB for Grid Aggregation at Scale

Problem statement

You have millions of points and want hexagon counts: per resolution, with areas and densities, as polygons a map can draw. Doing it in Python means a loop over h3.latlng_to_cell, a pandas groupby, a second loop for areas, a third for boundaries and a GeoDataFrame at the end.

DuckDB's community h3 extension moves all of that into one SQL statement. Measured on 13,464,117 GeoNames points read straight from Parquet:

resolution   occupied cells   median per cell   time
    3             17,210            124          0.47 s
    5            451,015              9          0.60 s
    7          5,798,146              1          0.89 s
    9         12,159,083              1          1.30 s

The surprising measurement is not the speed. It is that the obvious shortcut โ€” index once at a fine resolution, then roll up with h3_cell_to_parent โ€” is ten times faster than re-indexing and puts 6.51% of the points in a different cell.

Quick answer

install h3 from community;
load h3;

select h3_latlng_to_cell(lat, lon, 7) as cell,     -- latitude first
       count(*)                       as n
from read_parquet('geonames.parquet')
where lat is not null
group by 1
order by n desc;

On the full 13.46 million rows that returned 5,798,146 cells in 0.89 s. The cell is a UBIGINT; convert to the familiar string form only for display, with h3_h3_to_string(cell).

Bar chart of DuckDB H3 aggregation time over 13.46 million points at resolutions 3, 5, 7 and 9.
From resolution 7 upwards the median occupied cell holds one point, so the map shows individual records rather than density.

Step-by-step solution

1. Install and load the extension

import duckdb

con = duckdb.connect()
con.execute("install h3 from community; load h3;")
print(con.execute("select extension_version from duckdb_extensions() "
                  "where extension_name = 'h3'").fetchone())

With DuckDB 1.5.5 this reported v1.5.5 and registered 76 functions whose names start with h3_. install downloads once into ~/.duckdb/extensions/<version>/; load runs per connection. Community extensions are built by the community repository rather than the core team, so from community is required.

2. Get the argument order right

h3_latlng_to_cell(lat, lng, res) โ€” latitude first, like h3-py, and the opposite of st_point(x, y). Reversing the arguments does not raise: measured, it returned 897b8172ed3ffff for London instead of 89194ad14c3ffff, a cell 7,491 km away. If the input is a geometry column, extract explicitly:

select h3_latlng_to_cell(st_y(geom), st_x(geom), 7) from places;

The result matched h3-py exactly: 617438095025111039 as an integer, 89194ad14c3ffff as a string, for the same point in both libraries.

3. Choose the resolution from the distribution, not the map

The table above is the decision. At resolution 5 the median occupied cell held 9 points; at 7 it held 1. A hexagon map where most cells hold one point shows where records exist, not how dense they are. Run the aggregation at two or three resolutions and read the median before drawing anything.

4. Keep the cell as an integer

The string form is for people. Measured on the same 13.46 million resolution-9 cells, written to Parquet with zstd and then grouped:

stored as     file size     group by
UBIGINT       41.4 MB       0.20 s
VARCHAR       57.7 MB       0.65 s

Integers are 28% smaller on disk and 3.3ร— faster to group. Grouping by h3_latlng_to_cell_string directly was slower too: 1.16 s against 0.89 s at resolution 7.

5. Add area and density in the same query

select cell, n,
       h3_cell_area(cell, 'km^2')     as km2,
       n / h3_cell_area(cell, 'km^2') as per_km2
from (select h3_latlng_to_cell(lat, lon, 5) as cell, count(*) as n
      from read_parquet('geonames.parquet') group by 1);

The unit argument is required. Without it:

Binder Error: No function matches the given name and argument types 'h3_cell_area(UBIGINT)'.

The busiest resolution-5 cell held 6,716 points in 172.5 kmยฒ โ€” 38.9 per kmยฒ โ€” while the next held 4,718 in 245.8 kmยฒ. Resolution-5 cells range from 127.8 to 305.1 kmยฒ, so a count map and a density map rank the same cells differently.

6. Roll up only when an approximate answer is acceptable

Once a resolution-9 column is materialised, deriving resolution 5 from it is very fast:

h3_cell_to_parent(h9, 5), grouped         0.06 s    451,684 cells
h3_latlng_to_cell(lat, lon, 5), grouped   0.53 s    451,015 cells

The two answers differ. Measured over every row, 876,010 of 13,464,117 points (6.51%) have a resolution-9 parent that is not the resolution-5 cell containing the point, and 250,742 resolution-5 cells ended up with a different count. H3 children do not nest exactly inside their parents; Example 2 measures this on your own data.

7. Export polygons as GeoParquet

install spatial; load spatial;

copy (
    select h3_h3_to_string(cell) as h3, n,
           st_geomfromtext(h3_cell_to_boundary_wkt(cell)) as geom
    from (select h3_latlng_to_cell(lat, lon, 5) as cell, count(*) as n
          from read_parquet('geonames.parquet') group by 1)
) to 'h3_res5.parquet' (format parquet);

451,015 hexagons in 1.41 s, 49.0 MB, read back by GeoPandas with CRS OGC:CRS84. Build the polygons after aggregating: 451,015 boundaries are cheap; 13 million are not.

8. Check the export for hexagons that wrap the world

57 of those 451,015 polygons were wider than 180ยฐ of longitude โ€” cells straddling the antimeridian, drawn the long way round. They are a tiny fraction of the rows and would dominate any map of the Pacific. Example 3 flags them in the same statement that writes the file.

Grid comparing Parquet size and group-by time for H3 cells stored as UBIGINT and as VARCHAR.
Convert to strings in the final select, for display, and nowhere else.

Code examples

Example 1 โ€” counts, area and density in one call

import time

import duckdb


def h3_connection(threads=6):
    con = duckdb.connect()
    con.execute(f"set threads = {threads}")
    con.execute("set enable_progress_bar = false")
    con.execute("install h3 from community; load h3;")
    con.execute("install spatial; load spatial;")
    return con


def h3_counts(con, source, res, lat="lat", lng="lon", where="true"):
    """Points per H3 cell, with area and density, computed entirely in SQL."""
    started = time.perf_counter()
    frame = con.execute(f"""
        select h3_h3_to_string(cell)             as h3,
               n,
               h3_cell_area(cell, 'km^2')        as km2,
               n / h3_cell_area(cell, 'km^2')    as per_km2
        from (
            select h3_latlng_to_cell({lat}, {lng}, {res}) as cell, count(*) as n
            from {source}
            where {lat} is not null and {lng} is not null and {where}
            group by 1
        )
    """).df()
    print(f"res {res}: {len(frame):,} cells in {time.perf_counter() - started:.2f}s, "
          f"median {frame['n'].median():.0f} points per occupied cell")
    return frame
>>> con = h3_connection()
>>> f = h3_counts(con, "read_parquet('geonames.parquet')", 5)
res 5: 451,015 cells in 0.82s, median 9 points per occupied cell
>>> f7 = h3_counts(con, "read_parquet('geonames.parquet')", 7, where="feature_class = 'P'")
res 7: 2,985,431 cells in 2.23s, median 1 points per occupied cell

The timing includes building the pandas frame, which is why it is slower than the bare aggregate. Keep the result in DuckDB, or write it to a file, if the frame would be large.

Example 2 โ€” measure what a roll-up would change

def rollup_disagreement(con, source, fine, coarse, lat="lat", lng="lon"):
    """How many points change coarse cell if you roll up instead of re-indexing."""
    moved, total = con.execute(f"""
        select count(*) filter (
                   where h3_cell_to_parent(h3_latlng_to_cell({lat}, {lng}, {fine}), {coarse})
                         <> h3_latlng_to_cell({lat}, {lng}, {coarse})),
               count(*)
        from {source}
        where {lat} is not null
    """).fetchone()
    print(f"res {fine} -> {coarse}: {moved:,} of {total:,} points ({100 * moved / total:.2f}%) "
          f"belong to a different res-{coarse} cell than their res-{fine} parent")
    return moved / total
res 9 -> 5: 876,010 of 13,464,117 points (6.51%) belong to a different res-5 cell than their res-9 parent
res 8 -> 7: 961,507 of 13,464,117 points (7.14%) belong to a different res-7 cell than their res-8 parent
res 6 -> 5: 963,235 of 13,464,117 points (7.15%) belong to a different res-5 cell than their res-6 parent

One level of roll-up moves about 7% of points. Four levels at once moved slightly fewer, 6.51% โ€” the errors at successive levels do not simply add up. If downstream work joins to data indexed with h3_latlng_to_cell at the coarse resolution, roll-ups will not line up with it.

Example 3 โ€” export hexagons and flag the antimeridian

import os

import geopandas as gpd


def export_hexagons(con, counts_sql, path):
    """Write hexagon polygons to GeoParquet and flag the ones that wrap the map."""
    con.execute(f"""
        copy (
            select h3, n, geom,
                   st_xmax(geom) - st_xmin(geom) > 180 as crosses_180
            from (
                select h3_h3_to_string(cell) as h3, n,
                       st_geomfromtext(h3_cell_to_boundary_wkt(cell)) as geom
                from ({counts_sql})
            )
        ) to '{path}' (format parquet)
    """)
    frame = gpd.read_parquet(path)
    print(f"{len(frame):,} hexagons, {int(frame['crosses_180'].sum())} cross the antimeridian, "
          f"{os.path.getsize(path) / 1e6:.1f} MB, crs {frame.crs}")
    return frame
451,015 hexagons, 57 cross the antimeridian, 49.0 MB, crs OGC:CRS84

counts_sql must return columns named cell and n. The flag lets a map layer filter or repair those rows rather than discovering them as a band across the Pacific.

Explanation

Why the whole pipeline belongs in SQL

The index computation itself is not slow in Python: h3-py indexed 1,000,000 points in 0.75 s with string cells and 0.49 s with the numpy_int API. The cost is everything around it โ€” building a list of strings, hashing them in pandas, computing areas in another loop, building shapely polygons.

In DuckDB the cell is computed, hashed and grouped inside one vectorised plan across six threads, and only the aggregated rows reach Python. The whole measured script, which also materialised a 13.46-million-row table, peaked at 2.48 GB of resident memory.

Why integer cells are faster

An H3 index is a 64-bit integer; the hexadecimal string is a 15-character rendering of it. Grouping by the integer hashes 8 bytes and compares one machine word. Grouping by the string hashes and compares variable-length data, and Parquet stores it less compactly. The measured 3.3ร— difference is that overhead, repeated 13.46 million times.

Why rolling up is not the same as re-indexing

H3 uses an aperture of 7: each cell has seven children, arranged so that the children's union approximates the parent. Approximately. The outer six children each stick out of the parent on one side and leave a gap on another, so a point near the edge of a coarse cell can sit in a child whose parent is the neighbouring cell.

The fraction is stable: 7.14โ€“7.15% for a single level, measured twice at different resolutions. It matches a separate geometric measurement: across 500 random resolution-5 cells, 7.14% of the seven children's combined area lay outside the parent. Points are spread evenly enough that the share of misplaced points equals the share of misplaced area. Rolling up is the right operation when you want a strict hierarchy; re-indexing is right when you want each point in the coarse cell that contains it.

Why H3 costs more than a rectangular grid

A floor(lon / 0.1), floor(lat / 0.1) grid over the same rows took 0.14 s. H3 has to convert each coordinate to a 3D vector, find the nearest icosahedron face, project onto that face and then walk the resolution digits. That is several times the arithmetic of a floor, and it buys equal-ish areas, uniform neighbours and a global index โ€” the reasons to choose hexagons at all.

Two panels contrasting rolling H3 cells up with h3_cell_to_parent against re-indexing points at the coarse resolution.
Both are correct answers to different questions; mixing them in one join is the mistake.

Edge cases or notes

  • install h3 from community needs network access the first time. Offline machines and containers need the extension file baked in, like the spatial extension.
  • Every DuckDB upgrade needs a reinstall, because extensions are cached per DuckDB version.
  • h3_cell_area requires a unit โ€” 'km^2', 'm^2' or 'rads^2'. Measured, 'm^2' returned exactly a million times 'km^2'.
  • Null coordinates should be filtered before indexing rather than left to form a group.
  • _string variants exist for most functions (h3_latlng_to_cell_string, h3_polygon_wkt_to_cells_string); use them at the edges only.
  • Joining to cells from h3-py works on either form: h3_string_to_h3 converts strings to the integers DuckDB uses.
  • Polygon fills are available in SQL through h3_polygon_wkt_to_cells, with the same centre-containment rules as h3-py.
  • Very high resolutions make almost every point its own group; resolution 9 produced 12.16 million groups from 13.46 million rows, a hash table nearly the size of the input.

FAQ

How do I install the H3 extension in DuckDB?

Run install h3 from community, then load h3 in each connection. The install downloads once per DuckDB version; the load is per session.

Is DuckDB faster than h3-py for aggregation?

For the whole job, yes, because indexing, grouping, areas and boundaries stay in one engine. The index call alone is not the bottleneck: h3-py indexed a million points in 0.75 s, and DuckDB aggregated all 13.46 million at resolution 7 in 0.89 s.

Should I store H3 cells as strings or integers?

Integers. On 13.46 million resolution-9 cells the UBIGINT Parquet file was 41.4 MB against 57.7 MB, and grouping took 0.20 s against 0.65 s.

Can I compute a fine resolution once and roll it up?

Only if an approximate hierarchy is acceptable. Rolling resolution 9 up to 5 placed 6.51% of points in a different cell from indexing them at resolution 5 directly.

Why does h3_cell_area fail with a binder error?

It needs a unit argument. Call h3_cell_area(cell, 'km^2'); the single-argument form does not exist.

How do I get hexagon polygons out of DuckDB?

Use h3_cell_to_boundary_wkt with st_geomfromtext and COPY to Parquet. Check the width of each polygon, because cells on the antimeridian come out spanning the whole map.