dask-geopandas Uses More Memory or Time Than Plain GeoPandas
Problem statement
You reach for dask-geopandas to make a job faster and it gets slower, or it uses more memory than the single-threaded version it replaced.
This is the normal outcome for data that fits in memory. Measured on 950,256 building polygons computing total area:
plain geopandas 0.02 s
dask, 1 partition 0.14 s
dask, 4 partitions 0.13 s
dask, 8 partitions 0.13 s
dask, 16 partitions 0.18 s
Plain GeoPandas is 7.6 times faster, and more partitions make it worse. Nothing is misconfigured β the coordination costs more than the work.
Quick answer
Check four things before assuming a bug:
print(f"rows {len(gdf):,}")
print(f"memory {gdf.memory_usage(deep=True).sum() / 1e9:.2f} GB")
print(f"partitions {ddf.npartitions}")
print(f"tasks {len(ddf.dask):,}")
| symptom | likely cause |
|---|---|
| slower, small data | overhead exceeds the work β use GeoPandas |
| slower with more partitions | too many tasks |
| slow spatial join | no spatial_shuffle() |
| high memory | .compute() materialising everything |
| slow with idle CPU | tiny partitions, huge graph |
Step-by-step solution
1. Cause one: the data fits in memory
The measured penalty is 7.6Γ, and it is not recoverable by tuning. Dask's per-task overhead is roughly constant, and computing polygon areas is about 20 ms of real work across a million rows.
The rule of thumb: below a few gigabytes on a machine with room to spare, use GeoPandas. Dask earns its overhead when the alternative is not running at all.
2. Cause two: too many partitions
4 partitions 0.13 s
8 partitions 0.13 s
16 partitions 0.18 s
Each partition adds tasks, and each task adds scheduling. Once every core is busy, more partitions only add coordination.
Size partitions by memory β roughly 100 MB to 1 GB β rather than by core count.
3. Cause three: no spatial shuffle before a spatial operation
Dask partitions by row order, so rows adjacent in the file may be anywhere on the map. A spatial join then cannot exclude any partition pair and compares all of them.
left = left.spatial_shuffle()
joined = dgpd.sjoin(left, right)
The shuffle is itself expensive β a full data movement β so it pays on large joins and costs on small ones.
4. Cause four: computing more than once
a = ddf.area.sum().compute() # reads everything
b = ddf.length.sum().compute() # reads everything again
Each .compute() executes the graph from the start. Either build one expression, or .persist() the shared part:
ddf = ddf.persist() # in memory, once
a, b = dask.compute(ddf.area.sum(), ddf.length.sum())
5. Cause five: memory rising rather than staying flat
.compute() brings the whole result into the client process. If the result is the size of the input, dask has bought nothing and cost peak memory during assembly.
Reduce before computing β an aggregate, a filter, a groupby β or write partition-wise with to_parquet and never materialise it.
Code examples
Example 1 β a benchmark that answers the question
import time
import dask
import dask_geopandas as dgpd
def benchmark(gdf, operation, partitions=(1, 2, 4, 8, 16)):
"""Plain against dask at several partition counts."""
memory_gb = gdf.memory_usage(deep=True).sum() / 1e9
print(f" {len(gdf):,} rows, {memory_gb:.2f} GB")
start = time.time()
expected = operation(gdf)
plain = time.time() - start
print(f" plain geopandas {plain:8.3f} s")
best = None
for n in partitions:
ddf = dgpd.from_geopandas(gdf, npartitions=n)
start = time.time()
result = operation(ddf)
result = result.compute() if hasattr(result, "compute") else result
elapsed = time.time() - start
tasks = len(ddf.dask)
print(f" dask {n:3d} partitions {elapsed:8.3f} s "
f"({elapsed / plain:5.1f}x) {tasks:,} tasks")
if best is None or elapsed < best[1]:
best = (n, elapsed)
if best[1] > plain:
print(f" -> plain geopandas wins by {best[1] / plain:.1f}x; "
"dask only helps beyond memory")
else:
print(f" -> dask wins at {best[0]} partitions "
f"({plain / best[1]:.1f}x faster)")
return best
Running this once on a representative sample settles the question. It also catches the case where dask wins at 4 partitions and loses at 16, which is easy to miss.
Example 2 β diagnosing a graph that is too large
def graph_report(ddf):
"""Is the time going to work or to bookkeeping?"""
tasks = len(ddf.dask)
partitions = ddf.npartitions
print(f" {partitions} partitions, {tasks:,} tasks "
f"({tasks / max(partitions, 1):.0f} per partition)")
try:
sizes = ddf.map_partitions(
lambda part: part.memory_usage(deep=True).sum()).compute()
print(f" partition memory: min {sizes.min() / 1e6:.0f} MB, "
f"median {sizes.median() / 1e6:.0f} MB, "
f"max {sizes.max() / 1e6:.0f} MB")
if sizes.median() < 50e6:
print(" ! partitions under 50 MB β overhead will dominate")
if sizes.max() > 20 * max(sizes.min(), 1):
print(" ! very uneven partitions β one worker will finish last")
except Exception as exc:
print(f" could not measure partitions: {type(exc).__name__}")
if tasks > 100_000:
print(" ! over 100k tasks β the scheduler is likely the bottleneck")
return tasks
Uneven partitions are the quieter problem. Eight partitions of which one holds 80% of the rows runs at the speed of one core, and the dashboard shows seven idle workers.
Example 3 β the patterns that actually help
import dask
import dask_geopandas as dgpd
def efficient_pipeline(path, npartitions=8):
"""Read partitioned, stay lazy, reduce before computing."""
# 1. read a partitioned source, so reading is parallel from the start
ddf = dgpd.read_parquet(path, npartitions=npartitions)
# 2. filter early, so later steps carry less
ddf = ddf[ddf["building"].notna()]
# 3. build the whole expression before computing anything
ddf = ddf.assign(area=ddf.geometry.area)
by_type = ddf.groupby("building")["area"].sum()
counts = ddf.groupby("building").size()
# 4. compute both together, so the data is read once
areas, sizes = dask.compute(by_type, counts)
print(f" {len(areas)} building types, "
f"{sizes.sum():,} features, {areas.sum() / 1e6:.1f} kmΒ²")
return areas, sizes
dask.compute(a, b) computes both from one traversal. Calling .compute() on each separately reads the data twice, which is the most common way a dask pipeline ends up slower than the serial version it replaced.
Explanation
Why the overhead is constant per task
For each task, dask serialises a description, sends it to a worker, deserialises it, runs it, serialises the result and sends it back. That is milliseconds regardless of whether the task takes a microsecond or a minute.
Computing areas for a million polygons is roughly 20 ms of NumPy and GEOS work. Split into 16 tasks, the coordination is comparable to the work β measured, 0.18 s against 0.02 s.
The break-even is where per-task work exceeds per-task overhead. For cheap row-wise operations that means very large partitions; for expensive ones β overlays, complex predicates, per-row Python β it arrives much sooner.
Why memory can go up
Three mechanisms.
.compute() assembles the whole result in the client process, and during assembly both the partition results and the concatenated output exist.
Workers hold several partitions at once β one being processed, others queued β so peak worker memory is a multiple of the partition size.
And a shuffle materialises intermediate data across partitions, which for a spatial shuffle is the whole dataset rearranged.
The fix is to reduce before computing, or to write partition-wise and never assemble.
Why the dashboard is worth using
dask.distributed's dashboard shows where time goes: task execution, serialisation, scheduling, or waiting.
A profile dominated by scheduling means too many small tasks. One dominated by serialisation means partitions are being moved when they should not be. One dominated by a single long bar means uneven partitions.
None of those is visible from a wall-clock number, and each has a different fix.
Why the right question is "does this fit in memory"
Dask's value is running computations that otherwise cannot run. It is not a general speed-up.
If the data fits comfortably, the honest comparison is against GeoPandas with the obvious optimisations β vectorised operations rather than .apply, a spatial index, the right dtype. Those usually beat dask by more than dask's parallelism could ever recover.
If it does not fit, dask is competing against not running at all, and a 7.6Γ overhead on work you could not otherwise do is an excellent trade.
Edge cases or notes
- Below a few gigabytes, plain GeoPandas wins. Measured 7.6Γ here.
- More partitions is not better. 16 was slower than 8.
spatial_shuffle()before any spatial join, and know it costs a full data movement.dask.compute(a, b)computes both from one pass..persist()for an intermediate reused several ways.- Reduce before computing, or the client assembles the whole result.
- Uneven partitions run at the speed of the largest.
- Fix the GeoPandas code first β
.applyover rows is usually the real problem.
Internal links
- How to scale a GeoPandas job with dask-geopandas β when it does help
- Why GeoPandas is slow: the four real bottlenecks β check these before parallelising
- GeoPandas .apply() takes hours: how to fix it β the most common real cause
- Threads, processes and the GIL: parallelism in Python GIS explained β the alternatives
- How to write partitioned GeoParquet and query it with filters β the input layout that helps
- Lazy loading explained: why xarray reads nothing until you ask β the same compute-once rule
- How to replace a row loop with vectorised GeoPandas operations β the cheaper speed-up
- How to profile a slow Python GIS script and find the real bottleneck β before choosing a tool
FAQ
Why is dask-geopandas slower than GeoPandas?
Because coordination costs more than the work when the data fits in memory. Measured on 950,256 polygons, plain GeoPandas was 7.6Γ faster.
Should more partitions make it faster?
Only up to the point where every core is busy. Beyond that, extra partitions add scheduling β 16 was slower than 8 here.
Why is my dask spatial join so slow?
No spatial shuffle. Partitions are arbitrary row ranges by default, so the join must compare every partition pair.
Why does dask use more memory than GeoPandas?
.compute() assembles the whole result in the client, workers hold several partitions at once, and shuffles materialise intermediates. Reduce before computing.
How big should partitions be?
100 MB to 1 GB. Sizing by core count optimises for parallelism and ignores memory, which is usually the binding constraint.
When is dask-geopandas worth it?
When the data exceeds memory, when the per-row work is expensive, or when the input is already partitioned on disk.
What should I try before dask?
Vectorise the GeoPandas code, use the spatial index, pick better dtypes, and read only the columns you need. Those usually beat anything parallelism can recover.