How to Aggregate Millions of Points into a Grid with DuckDB
Problem statement
You have millions of points and you need a summary: counts per cell, a mean per hexagon, a density surface, a table of totals per region.
GeoPandas can do it, and its ceiling arrives sooner than expected. Aggregating 13,464,017 real points measured:
time peak memory
DuckDB 61.8 s 305 MB
GeoPandas 117.5 s 4,739 MB
The memory is what bites: 4.7 GB for a job that produced 251 rows of output. On an 8 GB laptop it is the difference between an answer and a crash, and on a shared machine it is the difference between finishing and being killed.
DuckDB streams. The aggregate is computed as the rows go past, so peak memory is a property of the output, not of the input.
Quick answer
Aggregate in SQL, and let the grid come from arithmetic rather than from a geometry join:
select floor(lon / 0.1) * 0.1 as cell_lon,
floor(lat / 0.1) * 0.1 as cell_lat,
count(*) as n,
avg(population) as mean_population
from read_parquet('places.parquet')
where lat is not null
group by 1, 2
order by n desc;
Measured on 13,464,017 rows: 0.06 s from Parquet, against 1.01 s from the equivalent 1.79 GB text file. The busiest cell held 33,850 points.
No geometry, no join, no index โ a grid cell is a rounded coordinate, and rounding is the fastest spatial operation there is.
Step-by-step solution
1. Decide whether you need geometry at all
Three ways to aggregate points, in increasing order of cost:
| Method | How | Cost |
|---|---|---|
| Rectangular grid | floor(x / size) * size |
arithmetic only |
| Named areas | spatial join to polygons | a spatial join |
| Hexagons / H3 | index function, or a join to a hex layer | between the two |
If the output is a heat map or a density surface, the rectangular grid is almost always enough and it is two orders of magnitude cheaper.
2. Choose the cell size deliberately
The cell size is an analytical choice, not a rendering detail. Too large and the pattern disappears; too small and every cell holds one point.
select 0.1 as cell_degrees, count(*) as cells, max(n) as busiest, avg(n) as mean_n
from (select floor(lon / 0.1), floor(lat / 0.1), count(*) n
from read_parquet('places.parquet') group by 1, 2);
Run it at three sizes and look at the distribution. A useful target is a mean of 10โ50 points per occupied cell; much below that and the map shows sampling noise.
3. Work in a projected CRS if the cells must be equal-area
A grid in degrees is not a grid on the ground: a 0.1ยฐ cell is 11.1 km ร 11.1 km at the equator and 11.1 km ร 5.6 km at 60ยฐ north. For a national analysis that is usually fine; for anything comparing densities across latitudes it is a bias, not a rounding error.
The fix is to project once and grid in metres:
create table projected as
select st_transform(st_point(lon, lat), 'EPSG:4326', 'EPSG:3035',
always_xy := true) as geom, *
from read_parquet('places.parquet');
select floor(st_x(geom) / 10000) * 10000 as cell_x,
floor(st_y(geom) / 10000) * 10000 as cell_y,
count(*) as n
from projected group by 1, 2;
4. Aggregate into real areas with a spatial join when you need names
When the output has to be per district or per country, the grid does not help and a join is the operation:
select a.name, count(*) as n
from read_parquet('places.parquet') p
join st_read('provinces.shp') a
on st_intersects(st_point(p.lon, p.lat), a.geom)
group by 1 order by n desc;
Measured on the full dataset: 13,464,017 points against 4,596 polygons in 61.8 s at 305 MB, producing 12,942,217 matches across 251 groups. The 521,800 unmatched points โ 3.9% โ were offshore and small-island features.
5. Keep the output small
The point of aggregating in the engine is that only the summary crosses into Python. A 13-million-row input producing 251 rows means 251 rows are materialised.
Resist select * before the aggregate. Every column named is a column read, and on a columnar file that is the difference between reading 0.06% of the file and 26% of it.
6. Turn the result into geometry only at the end
select cell_lon, cell_lat, n,
st_makeenvelope(cell_lon, cell_lat,
cell_lon + 0.1, cell_lat + 0.1) as geom
from grid;
Building 20,000 cell polygons is instant. Building 13 million point geometries to throw them away is not.
Code examples
Example 1 โ a grid aggregation with the cell size as a parameter
import duckdb
def grid_counts(source, cell_size=0.1, lon="lon", lat="lat",
extra_aggregates=None, con=None):
"""Count points per square cell. No geometry, no join, no index."""
con = con or duckdb.connect()
con.execute("set enable_progress_bar = false")
extra = ""
for alias, expression in (extra_aggregates or {}).items():
extra += f", {expression} as {alias}"
frame = con.execute(f"""
select floor({lon} / {cell_size}) * {cell_size} as cell_lon,
floor({lat} / {cell_size}) * {cell_size} as cell_lat,
count(*) as n{extra}
from {source}
where {lat} is not null and {lon} is not null
group by 1, 2
""").df()
print(f"{len(frame):,} occupied cells at {cell_size}ยฐ "
f"(mean {frame['n'].mean():.1f}, max {frame['n'].max():,} per cell)")
return frame
>>> grid = grid_counts("read_parquet('geonames.parquet')", cell_size=1.0,
... extra_aggregates={"mean_pop": "avg(population)"})
23,422 occupied cells at 1.0ยฐ (mean 574.8, max 33,850 per cell)
Example 2 โ choosing the cell size from the data
def cell_size_sweep(con, source, sizes=(0.02, 0.05, 0.1, 0.25, 0.5, 1.0),
lon="lon", lat="lat"):
"""The distribution at each size, so the choice is informed."""
print(f"{'size':>6} {'cells':>10} {'mean/cell':>10} {'median':>8} {'max':>10}")
for size in sizes:
row = con.execute(f"""
with grid as (
select floor({lon} / {size}) gx, floor({lat} / {size}) gy, count(*) n
from {source} where {lat} is not null group by 1, 2)
select count(*), avg(n), median(n), max(n) from grid
""").fetchone()
print(f"{size:6.2f} {row[0]:10,} {row[1]:10.1f} {row[2]:8.0f} {row[3]:10,}")
Measured on 13,464,017 points:
size cells mean/cell median max
0.02 6,414,445 2.1 1 631
0.10 961,781 14.0 5 3,610
0.25 224,369 60.0 14 6,722
0.50 71,921 187.2 33 14,678
1.00 23,422 574.8 64 33,850
A median of 1 โ the 0.02ยฐ row โ means the grid is finer than the data supports and the map will show sampling noise. A median in the tens is usually where a density surface becomes readable.
Example 3 โ a streaming aggregate under a hard memory limit
def aggregate_within_memory(source, group_expression, memory_limit="500MB",
temp_dir="/tmp/duckdb_spill"):
"""Prove the aggregate streams: cap the memory and watch it still finish."""
import duckdb
import resource
import time
con = duckdb.connect()
con.execute("set enable_progress_bar = false")
con.execute(f"set memory_limit = '{memory_limit}'")
con.execute(f"set temp_directory = '{temp_dir}'")
started = time.perf_counter()
frame = con.execute(f"""
select {group_expression} as bucket, count(*) as n
from {source} group by 1 order by n desc
""").df()
elapsed = time.perf_counter() - started
peak = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
print(f"{len(frame):,} groups in {elapsed:.1f}s, peak RSS {peak:,.0f} MB "
f"(limit was {memory_limit})")
return frame
Measured on the 2.2-million-point US join: unconstrained, 4.27 s at 227 MB; with memory_limit set to 200 MB, 4.18 s at 227 MB โ no slower, because the aggregate never needed the memory.
Explanation
Why rounding beats a geometry join for a grid
A grid cell is defined by arithmetic: floor(x / size). Computing it costs one division and one floor per row, both of which run in the CPU's vector units on a whole batch of values at once.
A spatial join to a grid layer costs geometry construction, a spatial structure, candidate testing and exact predicate evaluation for every point. It produces the same answer for a rectangular grid, and it is two orders of magnitude more work.
Use the join when the cells are not rectangles โ real administrative areas, hexagons from a published layer, irregular catchments.
Why streaming makes the memory constant
A GROUP BY needs to hold one entry per group, not one per row. DuckDB reads batches, updates the hash table of groups, and discards the batch.
That is why the 13.5-million-row aggregate peaked at 305 MB while GeoPandas needed 4,739 MB: GeoPandas materialised 13.5 million shapely geometries and a joined frame; DuckDB held 251 accumulator rows and a batch.
The consequence is practical. Memory use is bounded by the number of groups, so aggregating a billion rows into a thousand groups is no heavier than aggregating a million.
Why the file format decides the speed
The same aggregation measured 1.01 s from a 1.79 GB TSV and 0.06 s from a 446 MB Parquet file. The engine is identical; the difference is that Parquet lets it read two columns instead of nineteen.
Converting once with COPY ... TO ... (FORMAT PARQUET) is a few seconds and makes every subsequent aggregation an order of magnitude faster. For a dataset queried more than twice, it is free.
Why a degree grid is not an equal-area grid
A degree of longitude is 111 km at the equator, 78 km at 45ยฐ and 56 km at 60ยฐ. A 0.1ยฐ grid therefore has cells whose ground area halves between the tropics and northern Europe.
Counts per cell then mean different things at different latitudes, and a density map computed that way shows a latitude gradient that is not in the data. For a single country it rarely matters; for anything continental, project first and grid in metres.
Edge cases or notes
floor()on negative coordinates still bins correctly โ it rounds towards negative infinity, which is what a grid wants.- Null coordinates silently form a group; filter them explicitly.
- The busiest cell is worth looking at. Measured, one 1ยฐ cell held 33,850 points โ a data-density artefact, not a real hotspot.
- A degree grid is not equal-area. Project for anything continental.
group by 1, 2refers to output positions and is idiomatic in DuckDB.- Build the cell polygons after aggregating, not before.
median()in DuckDB is exact, which is convenient and costs more than an approximation on huge groups.- Convert to Parquet once if you will run more than a couple of these.
Internal links
- DuckDB spatial explained: a spatial database that is just a file โ the engine
- Columnar or row storage: why DuckDB is fast on wide tables โ where the 17ร comes from
- How to run a spatial join in DuckDB โ aggregating into real areas
- DuckDB's memory model: streaming, spilling and limits โ why the memory stays flat
- How to create a fishnet grid in GeoPandas โ the geometry version
- How to make a hexbin map of points โ hexagonal binning
- The modifiable areal unit problem explained โ why the cell size changes the answer
- Point density explained โ what the counts mean
- How to use H3 in DuckDB โ hexagonal cells instead of a degree grid
FAQ
How do I aggregate millions of points into a grid?
floor(x / size) * size as the group key. Measured on 13,464,017 points, that took 0.06 s from a Parquet file with no geometry involved.
How much memory does it need?
Bounded by the number of groups, not the number of rows. The 13.5-million-point aggregate peaked at 305 MB against GeoPandas' 4,739 MB.
Should I use a spatial join instead?
Only when the cells are not rectangles โ real administrative areas, published hexagons, irregular catchments. For a rectangular grid, rounding gives the same answer far more cheaply.
What cell size should I use?
Sweep several and look at the distribution. A median of one point per occupied cell means the grid is finer than the data supports; tens per cell usually reads well.
Does a grid in degrees distort the result?
Yes, away from the equator: a 0.1ยฐ cell is 11.1 km wide at the equator and 5.6 km at 60ยฐ north. Project and grid in metres for anything continental.
Why is it so much faster from Parquet?
Because a columnar file lets the engine read the two columns the query names. The same aggregation took 1.01 s from a 1.79 GB text file and 0.06 s from a 446 MB Parquet file.