Parallel Spatial Workflows with Dask and Joblib

TL;DR: Use joblib.Parallel(n_jobs=8, backend="loky") for independent units — tiles, folds, regions — and dask_geopandas.spatial_shuffle(by="hilbert") before dask_geopandas.sjoin when the data will not fit in memory. Send workers coordinate arrays or GeoParquet paths, never live geometry, and expand every tile by the variogram range before selecting conditioning data.

Why This Matters

Spatial work parallelises well because most of it is embarrassingly parallel and nobody notices. A kriging surface over a large area is not one linear system; it is hundreds of small ones, because kriging weights decay to nothing beyond the variogram range and a global solve is impossible anyway. Twelve thousand samples would need a 12,000 × 12,000 covariance matrix — 1.15 GB before the right-hand side — and the right-hand side for a 640,000-cell output grid is measured in terabytes. Tiling is not an optimisation here. It is what makes the problem exist at all, and once you have tiles you have independent units, and once you have independent units you have parallelism for free.

The trap is that the cheapest parallel tools were designed for arrays, and spatial data is not arrays. A GeoDataFrame carries GEOS objects that must be reconstructed in every worker, a spatial index that is deliberately dropped on pickling, and a CRS that GDAL and PROJ do not want shared across threads. Get those three details wrong and an eight-core run is slower than the loop it replaced. This page sits inside Memory-Efficient Processing, and the two concerns are the same concern: parallelism multiplies your peak memory by the worker count, so the techniques in reducing memory bottlenecks in geospatial workflows are a prerequisite rather than an alternative.

Choosing between joblib and Dask for spatial work A two by two matrix. The rows ask whether the working set fits in one process's memory; the columns ask whether the units of work are independent. Independent units that fit go to a single joblib Parallel call. Independent units that do not fit go to joblib over GeoParquet partition paths. Dependent work that fits goes to dask.delayed on the local scheduler. Dependent work that does not fit goes to dask-geopandas with a Hilbert spatial shuffle before the join. Which parallel tool for which spatial problem Two questions decide it, and only one of the four answers needs a cluster choose by two questions Units are independent Tasks share data or order Working set fits in RAM a few GB, one process joblib, and nothing else Parallel(n_jobs=-1)(delayed(f)(u) for u in units) kriging tiles · CV folds · one variogram per region no scheduler, no graph, no cluster to keep alive dask.delayed, local scheduler build the graph, let Dask order the work multi-stage pipelines with shared intermediates you get the dependency order and caching for free Larger than RAM streamed from disk joblib over partition paths each task opens its own GeoParquet piece per-tile raster stats · per-region model fits nothing large ever crosses the process boundary dask-geopandas spatial_shuffle(by=’hilbert’) then dgpd.sjoin out-of-core joins, overlays and dissolves the only quadrant that needs partition geometry The common mistake is the top-left cell answered with the bottom-right tool.

Environment and Version Pinning

bash
pip install \
  "geopandas==1.1.1" "shapely==2.1.1" "pyarrow==19.0.1" \
  "numpy==2.2.6" "scipy==1.15.2" \
  "joblib==1.5.1" "threadpoolctl==3.6.0" \
  "dask==2025.5.1" "distributed==2025.5.1" "dask-geopandas==0.5.0" \
  "pykrige==1.7.2" "gstools==1.7.0"
python
import time
import numpy as np
import geopandas as gpd
import gstools as gs
from joblib import Parallel, delayed, parallel_config
from pykrige.ok import OrdinaryKriging

Step-by-Step Implementation

1. Build a sample field with a known variogram

The halo width later depends on the variogram range, so the range has to be a measured quantity rather than a guess. Simulating the field with gstools means the model that generated it and the model fitted to it can be compared directly.

python
rng = np.random.default_rng(2026)
SIDE = 20_000.0          # 20 km square, EPSG:32630, metres
N_SAMPLES = 12_000

xs = rng.uniform(0.0, SIDE, N_SAMPLES)
ys = rng.uniform(0.0, SIDE, N_SAMPLES)

# Exponential covariance, practical range = 3 * len_scale = 1200 m.
# gstools does not simulate the nugget, so add it as measurement noise.
model = gs.Exponential(dim=2, var=1270.0, len_scale=400.0)
srf = gs.SRF(model, mean=420.0, seed=20260807)
pb = srf((xs, ys)) + rng.normal(0.0, np.sqrt(180.0), N_SAMPLES)

samples = gpd.GeoDataFrame(
    {"pb": pb}, geometry=gpd.points_from_xy(xs, ys), crs="EPSG:32630"
)
samples.to_parquet("data/pb_samples.parquet")

bin_centre, gamma = gs.vario_estimate(
    (xs, ys), pb, bin_edges=np.arange(0.0, 3000.0, 100.0),
    sampling_size=4000, sampling_seed=7,
)
fitted = gs.Exponential(dim=2)
fitted.fit_variogram(bin_centre, gamma, nugget=True)
print(fitted)
print(f"practical range = {3 * fitted.len_scale:.1f} m")
text
Exponential(dim=2, var=1263.4, len_scale=397.2, nugget=182.6)
practical range = 1191.6 m

2. Define the tiling and the worker function

The worker takes plain float arrays and a bounds tuple. Nothing in its signature is a geometry, a GeoDataFrame or a CRS object, which is the single decision that makes the rest of the run cheap.

python
TILE = 2500.0            # tile side, metres
HALO = 1191.6            # >= the practical range
CELL = 25.0              # output raster resolution
VARIO = {
    "variogram_model": "exponential",
    # PyKrige's exponential takes [partial sill, practical range, nugget]
    "variogram_parameters": [1263.4, 1191.6, 182.6],
}

tiles = [
    (i * TILE, j * TILE, (i + 1) * TILE, (j + 1) * TILE)
    for j in range(int(SIDE // TILE))
    for i in range(int(SIDE // TILE))
]
print(f"{len(tiles)} tiles, output grid "
      f"{int(SIDE // CELL)}x{int(SIDE // CELL)} cells")


def krige_tile(bounds, x, y, z, halo=HALO, cell=CELL, vario=VARIO):
    """Krige one tile, conditioning on every sample within `halo` of it."""
    x0, y0, x1, y1 = bounds
    sel = ((x >= x0 - halo) & (x <= x1 + halo) &
           (y >= y0 - halo) & (y <= y1 + halo))
    n = int(sel.sum())
    if n < 12:
        raise ValueError(f"tile {bounds} has only {n} conditioning points")

    gx = np.arange(x0 + cell / 2, x1, cell)
    gy = np.arange(y0 + cell / 2, y1, cell)

    ok = OrdinaryKriging(
        x[sel], y[sel], z[sel], exact_values=False, **vario
    )
    zhat, var = ok.execute("grid", gx, gy)
    return bounds, np.asarray(zhat, dtype="float32"), n
text
64 tiles, output grid 800x800 cells

3. Run the tiles with joblib and mosaic the result

python
x_arr = samples.geometry.x.to_numpy()
y_arr = samples.geometry.y.to_numpy()
z_arr = samples["pb"].to_numpy()

with parallel_config(backend="loky", inner_max_num_threads=1):
    for n_jobs in (1, 2, 4, 8):
        t0 = time.perf_counter()
        results = Parallel(n_jobs=n_jobs)(
            delayed(krige_tile)(b, x_arr, y_arr, z_arr) for b in tiles
        )
        print(f"n_jobs={n_jobs}: {time.perf_counter() - t0:6.1f} s")

counts = np.array([n for _, _, n in results])
print(f"conditioning points per tile: min {counts.min()}, "
      f"median {int(np.median(counts))}, max {counts.max()}")

nrow = ncol = int(SIDE // CELL)
mosaic = np.full((nrow, ncol), np.nan, dtype="float32")
for (x0, y0, _, _), zhat, _ in results:
    r0, c0 = int(y0 // CELL), int(x0 // CELL)
    mosaic[r0:r0 + zhat.shape[0], c0:c0 + zhat.shape[1]] = zhat
print(f"mosaic {mosaic.shape}, unfilled cells: {int(np.isnan(mosaic).sum())}")
text
n_jobs=1:   48.6 s
n_jobs=2:   25.9 s
n_jobs=4:   13.7 s
n_jobs=8:    7.4 s
conditioning points per tile: min 401, median 707, max 781
mosaic (800, 800), unfilled cells: 0

4. Measure the seams, with and without the halo

A tile-edge artefact is easy to miss on a colour ramp and trivial to measure: compare each pair of cells straddling a tile boundary against the ordinary cell-to-cell step inside a tile.

python
seam_cols = np.arange(1, int(SIDE // TILE)) * int(TILE // CELL)
seam_jump = np.abs(mosaic[:, seam_cols] - mosaic[:, seam_cols - 1])
interior = np.abs(np.diff(mosaic[:, 10:90], axis=1))

print(f"max seam jump    : {seam_jump.max():7.2f} mg/kg")
print(f"median seam jump : {np.median(seam_jump):7.2f} mg/kg")
print(f"median interior  : {np.median(interior):7.2f} mg/kg")

With HALO = 1191.6:

text
max seam jump    :    0.42 mg/kg
median seam jump :    0.05 mg/kg
median interior  :    0.06 mg/kg

Re-running the whole thing with HALO = 0.0 — each tile kriged only from the samples it contains — gives:

text
max seam jump    :   41.68 mg/kg
median seam jump :    6.31 mg/kg
median interior  :    0.06 mg/kg

The halo-free mosaic finishes in 2.1 seconds on the same eight workers instead of 7.4, and it is not a surface. A median seam jump of 6.31 mg/kg against a median interior step of 0.06 mg/kg is a grid of visible lines a hundred times the natural gradient, and the worst cell is off by 41.68 mg/kg — comparable to the field’s own standard deviation of 38.1.

What the halo buys, in plan and in profile On the left, a plan view: a dashed outer square is the 1,192 metre conditioning halo and a solid inner square is the 2,500 metre tile where predictions are actually made. Samples are scattered through both, with 707 inside the halo against 188 inside the tile alone. On the right, predicted lead is plotted along a transect crossing a tile seam. With the halo the profile is continuous; without it the two tiles disagree by 41.7 milligrams per kilogram at the seam. A · what one tile task reads B · predicted Pb across a tile seam read the halo, predict only the tile same tiles, same variogram, halo switched off and on tile 2 500 m 100 × 100 cells at 25 m halo 1 192 m = practical range of the fitted exponential model conditioning samples with halo: 707 without halo: 188 what the halo costs 3.8× the samples per tile, 7.4 s vs 2.1 s on 8 cores predicted Pb (mg/kg) distance across the tile seam (m) 380400420 440460 −500−2500 +250+500 tile seam no halo: 41.7 mg/kg step with halo: 0.42 mg/kg worst seam cell in the whole mosaic the halo is data the task reads and never predicts on seams are cheap to measure and impossible to unsee once mapped

5. Partition and join out of core with dask-geopandas

Kriging tiles are independent, so joblib was enough. A join is not: every left partition may match rows in any right partition, so the framework has to reason about which pairs can possibly intersect. That is what dask-geopandas adds over plain Dask, and it only works once both frames carry spatial_partitions.

python
import dask_geopandas as dgpd
from dask.distributed import Client

client = Client(n_workers=8, threads_per_worker=1,
                processes=True, memory_limit="4GB")

buildings = dgpd.read_parquet("data/buildings.parquet")   # 4.2 M polygons
areas = dgpd.read_parquet("data/output_areas.parquet")    # 34 211 polygons
print(buildings.npartitions, areas.npartitions, buildings.spatial_partitions)

buildings = buildings.spatial_shuffle(by="hilbert", npartitions=64)
areas = areas.spatial_shuffle(by="hilbert", npartitions=16)
print(type(buildings.spatial_partitions).__name__,
      len(buildings.spatial_partitions))
text
64 16 None
GeoSeries 64

With bounding geometry attached to every partition, the candidate pairs collapse. You can count them yourself before paying for the join:

python
left_boxes = gpd.GeoDataFrame(geometry=buildings.spatial_partitions)
right_boxes = gpd.GeoDataFrame(geometry=areas.spatial_partitions)
pairs = gpd.sjoin(left_boxes, right_boxes, predicate="intersects")
print(f"partition pairs to evaluate: {len(pairs)} of "
      f"{buildings.npartitions * areas.npartitions}")

joined = dgpd.sjoin(buildings, areas, how="inner", predicate="intersects")
per_area = joined.groupby("oa_code").size().compute()
print(per_area.describe()[["count", "mean", "min", "50%", "max"]])
text
partition pairs to evaluate: 118 of 1024
count    34211.000000
mean       122.706000
min          1.000000
50%        104.000000
max       1943.000000

Read the two numbers together: 118 partition pairs instead of 1,024 is 8.7 times less work scheduled, before any geometry is touched. Skipping the shuffle does not fail — it silently runs the full cross product, because row-order partitions each span the whole study area and every bounding box therefore intersects every other. The same reasoning governs the single-machine case in optimizing GeoPandas spatial joins for large datasets; Dask just applies it one level up, to partitions rather than rows.

Interpreting the Output

The joblib ladder is the honest measure of whether parallelism helped. Going from one worker to eight took 48.6 seconds down to 7.4, a speedup of 6.6 and a parallel efficiency of 82%. On independent, evenly sized units that is roughly what good looks like: the missing 18% is process start-up, the per-task pickling of three 96 kB arrays, and the tail effect of 64 tasks not dividing evenly into eight workers. An efficiency above 90% usually means the tasks are long; below 50% means something is serial, and on spatial work that something is almost always either payload serialisation or a thread pool fighting itself.

The seam metrics are the correctness check, and they are more important than the timings. Compare the median seam jump against the median interior step, not against zero: 0.05 versus 0.06 mg/kg says the seam is indistinguishable from ordinary cell-to-cell variation, which is the definition of a continuous mosaic. Warning sign: a maximum seam jump more than about three times the median interior step, which means at least one tile had too little conditioning data on one side.

For the Dask side, the number to watch is the partition-pair count. If it is close to left.npartitions * right.npartitions, spatial partitioning is not doing anything and you have a distributed cross product. Values in the range of two to four times max(npartitions) indicate compact, well-separated partitions. The Dask dashboard’s task stream is the second check: long uniform bars are healthy, a red band of communication means partitions are being shuffled repeatedly.

Critical Best Practices

Never let a live GeoDataFrame cross the process boundary

Anything the worker function closes over is pickled once per task, in the parent, one task at a time. Shapely 2 serialises a whole geometry column through a vectorised WKB round trip, which is much better than Shapely 1.8’s per-object pickling, but the receiving worker still has to reconstruct every GEOS object. On a 250,000-polygon building layer that is 0.41 seconds to write and 0.96 seconds to parse, against 0.74 seconds of actual join work. Pass a GeoParquet path, a WKB byte array via gdf.geometry.to_wkb(), or bare coordinate arrays as the kriging example does — and note that joblib memory-maps NumPy arrays above max_nbytes (1 MB by default), so large float arrays become free while geometry never does. Storing the partitions as GeoParquet in the first place, as covered in using GeoParquet for large spatial datasets, is what makes the path-passing pattern possible.

Where a parallel task's time actually goes Two horizontal stacked bars on a shared zero-to-four-second axis. The upper bar, where the worker function captures a GeoDataFrame, spends 0.41 seconds pickling in the parent, 0.96 seconds unpickling in the worker, 1.22 seconds rebuilding the spatial index and 0.74 seconds on the join, totalling 3.33 seconds. The lower bar, where the function captures a GeoParquet path, spends 0.31 seconds reading, the same 1.22 seconds on the index and 0.74 seconds on the join, totalling 2.27 seconds. Where a parallel task's time actually goes one spatial-join task over 250 000 building polygons · 12 tasks on 8 loky processes A · the worker function captures a GeoDataFrame 0.41 s0.96 s 1.22 s0.74 s 3.33 s per task pickle unpickle rebuild sindex sjoin parent, serial GEOS objects rebuilt never pickled the actual work B · the worker function captures a GeoParquet path 0.31 s1.22 s0.74 s 2.27 s per task 01 s2 s 3 s4 s A: 12 tasks on 8 workers → 10.7 s wall clock, 4.9 s of it the parent pickling one task at a time B: 12 tasks on 8 workers → 4.6 s wall clock; the parent sends twelve file paths the join itself is 22% of A

Size the halo from the variogram, not from intuition

The correct halo is the distance beyond which kriging weights are negligible, which for a fitted model is the practical range — three times len_scale for exponential, the range parameter itself for spherical. Anything shorter leaves prediction cells near the tile edge with no data on one side, and ordinary kriging fills the gap by re-estimating a local mean from a lopsided sample. Anything much longer is wasted: the conditioning set grows with the square of the halo width, and PyKrige’s cost grows with the square of the conditioning count again.

Prefer processes to threads for anything touching GDAL or PROJ

GDAL dataset handles are not safe to share across threads, PROJ transformation objects must not be reused across threads, and Shapely holds the GIL for part of every predicate call. The loky backend spawns fresh interpreters, which is slower to start and immune to all three problems; backend="threading" is only appropriate for kernels that spend their time inside NumPy or BLAS. In dask.distributed, that means processes=True, threads_per_worker=1 for any workflow that opens rasters or reprojects, which is also the configuration assumed in chunked raster processing with Dask-GeoPandas.

Stop joblib and BLAS from oversubscribing the CPU

PyKrige solves dense systems through NumPy, and NumPy’s BLAS defaults to one thread per core. Eight joblib workers each spawning eight BLAS threads is 64 threads on eight cores, and the resulting cache thrashing routinely halves throughput. parallel_config(backend="loky", inner_max_num_threads=1) sets the relevant environment variables inside each worker; threadpoolctl.threadpool_limits(limits=1, user_api="blas") does the same job for code you cannot wrap. Symptomatically this looks like a speedup that stalls around 2–3× no matter how many workers you add.

Accept that the spatial index is rebuilt in every worker

GeoPandas deliberately excludes _sindex from the pickled state, and there is no way to send a built index to a worker — the underlying GEOS STRtree is not serialisable. Every worker that calls sjoin, clip or any predicate pays to build it. The consequence is that fine-grained tasks are wasteful: a task that spends 1.22 seconds indexing to do 0.74 seconds of work has an index-to-work ratio above one. Make tasks larger, hand each worker a whole partition rather than a row group, and set batch_size in joblib so short tasks are dispatched in groups.

Troubleshooting

Symptom Likely cause Fix
Speedup stalls at 2–3× with n_jobs=8 BLAS threads inside each worker oversubscribing the cores Wrap the call in parallel_config(backend="loky", inner_max_num_threads=1)
Wall clock barely improves and CPU sits near 100% on one core The parent is pickling a large payload serially between dispatches Pass GeoParquet paths or to_wkb() bytes instead of the GeoDataFrame
Faint grid lines at regular spacing in the output raster Halo shorter than the practical range, or zero Set HALO to at least 3 * fitted.len_scale and re-measure the seam jump
dgpd.sjoin schedules left.npartitions * right.npartitions tasks spatial_partitions is None on one or both frames Call spatial_shuffle(by="hilbert"), or calculate_spatial_partitions() if partitions are already spatially coherent
Workers killed by the OS, or MemoryError at high n_jobs Peak memory scales with worker count, not with the dataset Reduce n_jobs, shrink TILE, or lower memory_limit so Dask spills instead
Cannot find proj.db raised only inside workers Spawned interpreters do not inherit a conda-activated PROJ path Export PROJ_DATA in the parent environment before creating the pool
RuntimeError about starting a new process on import No if __name__ == "__main__": guard around the entry point Guard the script; in notebooks, define worker functions in an imported module

Next Steps

Parallel workers multiply peak memory by the worker count, so pair this with reducing memory bottlenecks in geospatial workflows before raising n_jobs, and store the partitions the workers read as described in using GeoParquet for large spatial datasets.

Frequently Asked Questions

When should I reach for Dask instead of joblib?

Reach for Dask when the working set does not fit in one process, or when the units of work depend on each other. A join, an overlay or a dissolve needs data from more than one partition, so it needs a task graph and a shuffle; that is Dask territory. Kriging a thousand tiles, fitting one variogram per region, or running cross-validation folds are independent units, and joblib does those with a single Parallel call and no scheduler, no cluster and no serialisation of an execution graph.

Why is passing a GeoDataFrame to joblib workers so expensive?

Anything captured by the function you pass to Parallel is pickled once per task, in the parent process, one task at a time. Shapely 2 serialises a geometry column through a vectorised WKB round trip, which is far better than Shapely 1.8 but still means the worker must rebuild every GEOS object on arrival. On 250,000 building polygons that is roughly 0.41 seconds to write and 0.96 seconds to parse, against 0.74 seconds of actual join work. Send coordinate arrays or GeoParquet paths instead.

How wide should the halo around each tile be?

At least the practical range of the variogram, because beyond that distance the kriging weights are effectively zero and adding more conditioning data changes nothing. For the exponential model fitted here the practical range is three times the length scale, 1,192 metres, so a 1,192 metre halo drops the worst seam discontinuity from 41.68 to 0.42 mg/kg. A wider halo is safe but not free: the conditioning set grows with the square of the halo width.

Can I use threads instead of processes for spatial work?

Rarely. GDAL objects are not safe to share between threads, PROJ transformation objects must not be reused across threads, and Shapely predicate calls hold the GIL for part of their work. Threads do help for pure NumPy or BLAS-heavy kernels that release the GIL, but any task that opens a raster, reprojects, or touches a spatial index should run in its own process. Use the loky backend in joblib, or processes=True with threads_per_worker=1 in dask.distributed.


Related

← Back to Memory-Efficient Processing