Skip to main content

Convert to H3 on the Fly

For small data (under about 100 MB), convert to H3 inside a UDF each time it runs. There is no ingestion step, so you can change the resolution or the value you aggregate at any time. For large datasets, ingest once and read back only what you need: see Get your data into H3.

Try the examples on this page in Canvas →


Points​

Count or aggregate points per hex at a fixed resolution with DuckDB's h3_latlng_to_cell().

Point Count to Hex​

The following example uses a simple CSV of 311 calls in the New York City area, showing a heatmap of calls per hex 9 cell:

Open this UDF in Canvas →

Code
@fused.udf
def udf(
res: int = 9
):
noise_311_link = "https://gist.githubusercontent.com/kashuk/670a350ea1f9fc543c3f6916ab392f62/raw/4c5ced45cc94d5b00e3699dd211ad7125ee6c4d3/NYC311_noise.csv"
# Load common utilities (includes duckdb helper)
common = fused.load("https://github.com/fusedio/udfs/tree/b6786d6/public/common/")
con = common.duckdb_connect()

df = con.execute(
"""
SELECT
h3_latlng_to_cell(lat, lng, ?) AS hex,
COUNT(*) AS cnt
FROM read_csv_auto(?)
WHERE lat IS NOT NULL AND lng IS NOT NULL
GROUP BY 1
""",
[res, noise_311_link],
).df()

# Visualize as a heatmap of call counts per hex
map_utils = fused.load("https://github.com/fusedio/udfs/tree/3ada22d/community/milind/map_utils")
layers = [
{
"type": "hex",
"data": df,
"name": "311 Noise Complaints",
"config": {
"style": {
"fillColor": {
"type": "continuous",
"attr": "cnt",
"domain": [0, 200],
"steps": 20,
"palette": "OrYel",
},
"filled": True,
"extruded": False,
"stroked": False,
"opacity": 0.7,
}
},
},
]
return map_utils.deckgl_layers(layers=layers, basemap="dark")

Requirements​

  • Small single (< 100MB) file (GeoJSON, CSV, Parquet, etc.). In example:
lat,lng
40.7128,-74.0060
40.7128,-74.0060
  • Hexagon resolution (10, 11, 12, etc.). In this example:
res: int = 9
  • Field to hexagonify & Aggregation function (max 'population', avg 'income', mean 'elevation', etc.). In this example simply counting the number of calls per hex:
df = con.execute(
"""
SELECT
h3_latlng_to_cell(lat, lng, ?) AS hex,
COUNT(*) AS cnt
FROM read_csv_auto(?)
WHERE lat IS NOT NULL AND lng IS NOT NULL
GROUP BY 1
""",
[res, noise_311_link],
).df()

Polygons and vectors​

Turn each geometry into hexes with common.gdf_to_hex(), then aggregate per hex.

Polygon to Hex​

Set your UDF to Tile UDF Mode

This example runs in Tile UDF Mode: each viewport tile is converted on its own, and the hex resolution follows the zoom level.

The following example uses a simplified Census Block Group dataset of the state of California, showing population density per hex:

Open this UDF in Canvas →

Code
@fused.udf
def udf(
bounds: fused.types.Bounds = [-125.0, 32.0, -114.0, 42.0],
min_hex_cell_res: int = 11, # Increase this if working with high res local data
max_hex_cell_res: int = 4,
):
import pandas as pd
import geopandas as gpd

common = fused.load("https://github.com/fusedio/udfs/tree/b6786d6/public/common/")
path = "s3://fused-asset/demos/catchment_analysis/simplified_acs_bg_ca_2022.parquet"

# Dynamic H3 resolution
def dynamic_h3_res(b):
z = common.estimate_zoom(b)
return max(min(int(2 + z / 1.5), min_hex_cell_res), max_hex_cell_res)

parent_res = max(dynamic_h3_res(bounds) - 1, 0)

# Load and clip data
gdf = gpd.read_parquet(path)
tile = common.get_tiles(bounds, clip=True)
gdf = gdf.to_crs(4326).clip(tile)

# Early exit if empty
if len(gdf) == 0:
return pd.DataFrame(columns=["hex", "POP", "density", "pct"])

# Hexify: one row per (polygon, hex) pair, with the polygon's hex count in cell_count
con = common.duckdb_connect()
df_hex = common.gdf_to_hex(gdf, res=parent_res, add_latlng_cols=None)
con.register("df_hex", df_hex)

# Aggregate to parent hexagons: split each polygon's population evenly across its hexes
query = """
WITH agg AS (
SELECT
h3_cell_to_parent(hex, ?) AS hex,
SUM(POP / cell_count) AS POP
FROM df_hex
GROUP BY hex
)
SELECT
hex,
POP,
POP / h3_cell_area(hex, 'km^2') AS density,
POP * 100.0 / SUM(POP) OVER () AS pct
FROM agg
ORDER BY POP DESC
"""

return con.execute(query, [parent_res]).df()

Vector to H3​

This example converts Overture building footprints in a small area of Ajaccio (Corsica) in a single run, not per tile, and shows the result on a map widget:

Limits

This method is for datasets < 100k vectors. Run in Single (viewport) mode, not Tiled.

Code
@fused.udf
def udf(
bounds: fused.types.Bounds = [8.72, 41.91, 8.76, 41.935] # small test area (central Ajaccio)
):
common = fused.load("https://github.com/fusedio/udfs/tree/b6786d6/public/common/")
res = common.bounds_to_res(bounds, offset=0)
res = max(9, res)

gdf = get_data()

# Clip to the bounds so we only process a small test region
minx, miny, maxx, maxy = bounds
gdf = gdf.cx[minx:maxx, miny:maxy]
print(f"Buildings in test area: {gdf.shape[0]}")
if gdf.empty:
print("No buildings in these bounds.")
return

if gdf.shape[0] > 100_000:
print("Dataset too large. Contact info@fused.io for scaling.")
return

# Single (non-map) run over the clipped dataset
df = hexagonify_udf(gdf[["geometry"]], res=res)
df = df.reset_index(drop=True)
df = df.groupby('hex').sum(['cnt', 'area']).sort_values('hex').reset_index()[['hex', 'cnt', 'area']]
df['area'] = df['area'].astype(int)

return df


@fused.udf
def hexagonify_udf(geometry, res: int = 12):
common = fused.load("https://github.com/fusedio/udfs/tree/b6786d6/public/common/")
gdf = common.to_gdf(geometry)
gdf = common.gdf_to_hex(gdf[['geometry']], res=15)
con = common.duckdb_connect()
df = con.execute(
"""
SELECT h3_cell_to_parent(hex, ?) AS hex,
COUNT(1) AS cnt,
SUM(h3_cell_area(hex, 'm^2')) AS area
FROM gdf
GROUP BY 1
ORDER BY 1
""",
[res],
).df()
return df


@fused.cache
def get_data():
gdf = fused.get_chunk_from_table(
"s3://us-west-2.opendata.source.coop/fused/overture/2025-12-17-0/theme=buildings/type=building/part=3", 10, 0
)
return gdf

For more geometries, split them and run hexagonify_udf on each chunk in parallel with .map(). To query a large vector dataset by area or cell later, ingest it once with partition, index & query.

Open the Vector to H3 UDF in Canvas →

Need help with larger datasets?

Reach out to our team at info@fused.io


Rasters​

Average the pixels that fall in each hex.

Raster to Hex​

Like Polygon to Hex, this example runs in Tile UDF Mode. It uses AWS's Terrain Tiles tiled GeoTiff data. Hexagons show elevation data:

Open this UDF in Canvas →

Code
@fused.udf
def udf(bounds: fused.types.Bounds = [-73.983, 40.763, -73.969, 40.773]):
import pandas as pd
import rioxarray

common = fused.load("https://github.com/fusedio/udfs/tree/b6786d6/public/common/")
tile = common.get_tiles(bounds, clip=True)

x, y, z = tile.iloc[0][["x", "y", "z"]]
url = f"https://s3.amazonaws.com/elevation-tiles-prod/geotiff/{z}/{x}/{y}.tif"

res_offset = 0
h3_size = max(min(int(3 + z / 1.5), 12) - res_offset, 2)

con = common.duckdb_connect()
da_tiff = rioxarray.open_rasterio(url).squeeze(drop=True).rio.reproject("EPSG:4326")
df_tiff = da_tiff.to_dataframe("data").reset_index()[["y", "x", "data"]]

df = con.execute(
"""
SELECT
h3_latlng_to_cell(y, x, ?) AS hex,
AVG(data) AS elevation
FROM df_tiff
GROUP BY 1
""",
[h3_size],
).df()
df = df[df["elevation"] > 0]

map_utils = fused.load("https://github.com/fusedio/udfs/tree/3ada22d/community/milind/map_utils")
layers = [
{
"type": "hex",
"data": df,
"name": "Terrain Elevation Hexagons",
"config": {
"style": {
"fillColor": {
"type": "continuous",
"attr": "elevation",
"domain": [0, 3000],
"steps": 20,
"palette": "OrYel",
},
"filled": True,
"extruded": False,
"stroked": False,
"opacity": 0.7,
}
},
},
]
return map_utils.deckgl_layers(layers=layers, basemap="dark")

For large rasters, ingest once with the raster ingestion pipeline instead of converting on every request.