Threads, Processes and the GIL: Parallelism in Python GIS Explained

On this page

Problem statement

Eight threads, and the job gets slower. This is a per-feature Python calculation β€” each parcel's area through apply β€” on HM Land Registry's 338,611 Birmingham parcels (set up under Code examples), serially and split across eight threads:

import time
from concurrent.futures import ThreadPoolExecutor

def area_by_loop(chunk):
    return chunk.apply(lambda g: g.area)          # Python, one feature at a time

start = time.perf_counter()
serial = [area_by_loop(c) for c in chunks]
print(f"serial loop: {time.perf_counter() - start:.2f} s")

start = time.perf_counter()
with ThreadPoolExecutor(max_workers=8) as pool:
    threaded = list(pool.map(area_by_loop, chunks))
print(f"8 threads:   {time.perf_counter() - start:.2f} s")
serial loop: 0.49 s
8 threads:   1.14 s

The same eight threads on a geometry operation β€” a 25 m buffer of every parcel β€” do the opposite:

def buffer_chunk(chunk):
    return chunk.buffer(25)                       # GEOS, which releases the GIL

start = time.perf_counter()
[buffer_chunk(c) for c in chunks]
print(f"serial buffer: {time.perf_counter() - start:.2f} s")

start = time.perf_counter()
with ThreadPoolExecutor(max_workers=8) as pool:
    list(pool.map(buffer_chunk, chunks))
print(f"8 threads:     {time.perf_counter() - start:.2f} s")
serial buffer: 6.63 s
8 threads:     1.31 s

Both outcomes are predictable once you know two things: what Python's Global Interpreter Lock actually locks, and where your GIS work is really spending its time. Geospatial Python is unusual here β€” much of the heavy lifting happens in C libraries that release the lock, so the usual "threads are useless in Python" advice is wrong about half the time.

Quick answer

Choose by what the work is waiting for:

  1. I/O-bound (reading files, network, database) β†’ threads; GDAL releases the GIL during I/O
  2. CPU-bound in C (GEOS geometry ops, NumPy, rasterio) β†’ threads often work, because those libraries release the GIL
  3. CPU-bound in Python (per-feature loops, apply, dict building) β†’ processes, or rewrite it vectorised
  4. Whole-file batches β†’ processes, one file per worker, no shared state
  5. Always measure; parallel overhead can exceed the saving
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
import geopandas as gpd

# I/O-bound: many small reads β†’ threads
def read_info(path):
    import pyogrio
    return pyogrio.read_info(path)

with ThreadPoolExecutor(max_workers=16) as pool:
    infos = list(pool.map(read_info, paths))

# whole-file CPU work β†’ processes (module-level function, paths not objects)
def process_one(path_str: str) -> str:
    gdf = gpd.read_file(path_str)
    out = gdf.to_crs(gdf.estimate_utm_crs()).buffer(25)
    dest = path_str.replace("raw", "out")
    gpd.GeoDataFrame(geometry=out).to_file(dest, driver="GPKG")
    return dest

if __name__ == "__main__":
    with ProcessPoolExecutor(max_workers=4) as pool:
        list(pool.map(process_one, [str(p) for p in paths]))

The if __name__ == "__main__" guard is not optional for processes, and passing paths rather than GeoDataFrames is what keeps the transfer cost near zero.

What the GIL actually blocks

Python bytecode holding the GIL versus C extensions releasing it. what the lock actually blocksholds the GILβœ•per-feature Python loopsβœ•.apply() over a GeoSeriesβœ•building dicts and listsβœ•threads give no speed-up herereleases the GILβœ“GEOS geometry operationsβœ“GDAL reading and writingβœ“NumPy array arithmeticβœ“threads genuinely use coresGeospatial Python is unusual β€” much of the work is not holding the lock spatialworkflow.io
The lock protects the interpreter, not your computation β€” and GEOS, GDAL and NumPy let go of it.

The GIL, threads, processes and their costs

Workload types and the right parallelism for each. four workloads, four answersusebecausereading many filesβœ“threadsGDAL releases the GILGEOS geometry workβœ“threadsGEOS releases the GILper-feature Python!vectorise firstthe loop is the problemwhole-file batchesβœ“processesindependent, coarse unitsVectorising usually beats parallelising a slow implementation spatialworkflow.io
Four kinds of work, four different answers β€” and one of them is β€œdo not parallelise”.

What the GIL is

The Global Interpreter Lock is a mutex inside CPython that ensures only one thread executes Python bytecode at a time. It exists because CPython's memory management β€” reference counting on every object β€” is not thread-safe, and a lock around the interpreter was far simpler and faster for single-threaded code than fine-grained locking everywhere.

The crucial detail is what it does not cover. A C extension can release the GIL while it works and reacquire it before touching Python objects. During that window other threads run freely.

# rough shape of a GIL-releasing C function
# Py_BEGIN_ALLOW_THREADS
#     GEOSIntersection_r(handle, a, b);      /* no Python objects touched */
# Py_END_ALLOW_THREADS

Shapely 2 releases the GIL around GEOS calls. GDAL releases it during I/O. NumPy releases it for most array operations. That is why threads are genuinely useful in a geospatial workload in a way they are not in, say, a pure-Python parser.

Find out what your work is actually doing

import time
import geopandas as gpd

def profile_step(fn, *args, label=""):
    t0 = time.perf_counter()
    cpu0 = time.process_time()
    result = fn(*args)
    wall = time.perf_counter() - t0
    cpu = time.process_time() - cpu0
    kind = "CPU-bound" if cpu / wall > 0.8 else "I/O-bound or waiting"
    print(f"{label:28} wall {wall:6.2f}s  cpu {cpu:6.2f}s  β†’ {kind}")
    return result

gdf = profile_step(gpd.read_file, "data/parcels.gpkg", label="read_file")
profile_step(lambda g: g.to_crs(3857), gdf, label="to_crs")
profile_step(lambda g: g.buffer(25), gdf.geometry, label="buffer (GEOS)")
profile_step(lambda g: g.apply(lambda x: x.area), gdf.geometry, label="apply (Python loop)")
read_file                    wall   0.50s  cpu   0.50s  β†’ CPU-bound
to_crs                       wall   2.26s  cpu   2.26s  β†’ CPU-bound
buffer (GEOS)                wall   6.39s  cpu   6.38s  β†’ CPU-bound
apply (Python loop)          wall   0.45s  cpu   0.45s  β†’ CPU-bound

If CPU time is close to wall time, the work is computing; if it is much lower, it is waiting. That single ratio tells you which parallelism to reach for β€” with one caveat the table shows: a read from a local disk the operating system has cached is computing, not waiting, and counts as CPU-bound.

Serial and 8-thread timings for GEOS buffering and a Python apply loop over 338,611 parcels. 338,611 parcels, serial vs 8 threadsworkserial8 threadsbuffer(25) β€” GEOS6.63 s1.31 s β€” GIL releasedapply(g.area) β€” Python0.49 s1.14 s β€” slowerThreads help exactly when the work happens outside the interpreter spatialworkflow.io
Measured at the top of this page.

Threads for I/O

from concurrent.futures import ThreadPoolExecutor, as_completed
import pyogrio

def inventory(paths, workers=16):
    rows = []
    with ThreadPoolExecutor(max_workers=workers) as pool:
        futures = {pool.submit(pyogrio.read_info, p): p for p in paths}
        for future in as_completed(futures):
            path = futures[future]
            try:
                info = future.result()
                rows.append({"path": str(path), "features": info["features"],
                             "crs": info["crs"]})
            except Exception as exc:
                rows.append({"path": str(path), "error": str(exc)})
    return rows

import rasterio

def raster_header(path):
    with rasterio.open(path) as src:                 # an HTTP range request for the header
        return {"path": path, "size": (src.width, src.height), "crs": str(src.crs)}

batches = [remote_rasters[i * 24:(i + 1) * 24] for i in range(4)]    # different files each run: no cache hits
runs = [({}, 1), ({}, 16), ({"GDAL_DISABLE_READDIR_ON_OPEN": "EMPTY_DIR"}, 1),
        ({"GDAL_DISABLE_READDIR_ON_OPEN": "EMPTY_DIR"}, 16)]
for (options, workers), batch in zip(runs, batches):
    with rasterio.Env(**options):
        start = time.perf_counter()
        with ThreadPoolExecutor(max_workers=workers) as pool:
            headers = list(pool.map(raster_header, batch))
    label = "EMPTY_DIR" if options else "default"
    print(f"{label:9} {workers:>2} thread(s): {len(headers)} remote headers in {time.perf_counter() - start:5.1f} s")
default    1 thread(s): 24 remote headers in  49.3 s
default   16 thread(s): 24 remote headers in  41.7 s
EMPTY_DIR  1 thread(s): 24 remote headers in  14.0 s
EMPTY_DIR 16 thread(s): 24 remote headers in   1.6 s

Reading headers over the network is almost entirely waiting β€” here, 24 Sentinel-2 band files on AWS per run, and a different 24 each time so none is served from GDAL's cache. With GDAL's defaults, though, sixteen threads barely help: on each open GDAL first tries to list the remote "directory", and those listing requests dominate and do not overlap. Tell it not to with GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR and each open is a couple of range requests β€” faster on its own, and sixteen threads then overlap the waits as they should. There is no pickling, no memory multiplication and no __main__ guard needed.

Threads for GEOS-bound geometry work

import numpy as np

def buffer_chunk(geoms):
    return geoms.buffer(25)          # GEOS releases the GIL

edges = np.linspace(0, len(gdf), 9).astype(int)
chunks = [gdf.geometry.iloc[a:b] for a, b in zip(edges[:-1], edges[1:])]
print(type(np.array_split(gdf.geometry, 8)[0]).__name__, "β€” what np.array_split returns now")

with ThreadPoolExecutor(max_workers=8) as pool:
    parts = list(pool.map(buffer_chunk, chunks))

buffered = gpd.GeoSeries(np.concatenate([p.values for p in parts]), crs=gdf.crs)
print(len(buffered), buffered.crs)
ndarray β€” what np.array_split returns now
338611 EPSG:27700

Slice with iloc, not np.array_split: with current NumPy and pandas, array_split on a GeoSeries hands back plain NumPy arrays of geometries, and .buffer fails on them.

This genuinely uses multiple cores, because each thread spends its time inside GEOS with the lock released. It also shares memory, so there is no per-worker copy of the data β€” the big advantage over processes.

Processes for Python-bound work

Whole-file batches go to processes, and the worker has to live in a real module so the child processes can import it. The folder here is the Birmingham parcels split into one file per ward, 69 files:

%%writefile ward_batch.py
from concurrent.futures import ProcessPoolExecutor
from pathlib import Path
import sys, time
import geopandas as gpd

def process_file(path_str: str) -> dict:
    path = Path(path_str)
    gdf = gpd.read_file(path)
    gdf["footprint_m2"] = gdf.geometry.apply(lambda g: g.buffer(25).area)   # Python per feature
    dest = Path("data/out") / f"{path.stem}.gpkg"
    gdf.to_file(dest, driver="GPKG")
    return {"file": path.name, "features": len(gdf)}

if __name__ == "__main__":
    Path("data/out").mkdir(parents=True, exist_ok=True)
    paths = [str(p) for p in sorted(Path("data/wards").glob("*.gpkg"))]
    workers = int(sys.argv[1])
    start = time.perf_counter()
    if workers == 0:
        results = [process_file(p) for p in paths]
    else:
        with ProcessPoolExecutor(max_workers=workers) as pool:
            results = list(pool.map(process_file, paths))
    print(f"workers={workers}: {len(results)} files, {sum(r['features'] for r in results):,} parcels, "
          f"{time.perf_counter() - start:.1f} s")
import subprocess, sys
for workers in (0, 4, 8):
    print(subprocess.run([sys.executable, "ward_batch.py", str(workers)], capture_output=True, text=True).stdout.strip())
workers=0: 69 files, 337,909 parcels, 16.5 s
workers=4: 69 files, 337,909 parcels, 5.1 s
workers=8: 69 files, 337,909 parcels, 3.1 s

Whole-file batches are the ideal process workload: each worker is independent, arguments are small strings, and results are small dicts. The costs β€” start-up, pickling, memory per worker β€” are amortised over the work per file, and the per-feature Python loop that would hold the GIL in a thread runs in parallel because each process has a GIL of its own.

The trap: oversubscription

import os

# GDAL, NumPy's BLAS and PROJ each start their own thread pools
for var in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS",
            "GDAL_NUM_THREADS", "NUMEXPR_NUM_THREADS"):
    os.environ.setdefault(var, "1")

import geopandas as gpd     # import after setting the variables

Four worker processes, each spawning eight BLAS threads, is thirty-two threads competing for eight cores. They spend their time context-switching, and the parallel version is slower than the serial one. Setting the native thread counts to 1 in worker processes is the standard fix β€” and the most common explanation for "parallel made it worse".

The other trap: transferring data

%%writefile transfer.py
from concurrent.futures import ProcessPoolExecutor
import resource, sys, time
import geopandas as gpd

def peak_mb():
    return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024

def count_rows(frame):
    return len(frame), peak_mb()

def work_on_window(args):
    path, start, count = args
    part = gpd.read_file(path, engine="pyogrio", skip_features=start, max_features=count)
    return len(part), peak_mb()

if __name__ == "__main__":
    path = "data/parcels.gpkg"
    start = time.perf_counter()
    if sys.argv[1] == "send":
        big = gpd.read_file(path)                                # the whole frame, pickled to every worker
        with ProcessPoolExecutor(4) as pool:
            results = list(pool.map(count_rows, [big] * 4))
    else:
        windows = [(path, s, 100_000) for s in range(0, 400_000, 100_000)]
        with ProcessPoolExecutor(4) as pool:                    # each worker reads its own slice
            results = list(pool.map(work_on_window, windows))
    total = sum(rows for rows, _ in results)
    print(f"{sys.argv[1]:7} {total:>9,} rows  {time.perf_counter() - start:5.1f} s  "
          f"largest worker {max(mb for _, mb in results):,.0f} MB")
for mode in ("send", "windows"):
    print(subprocess.run([sys.executable, "transfer.py", mode], capture_output=True, text=True).stdout.strip())
send    1,354,444 rows    2.6 s  largest worker 562 MB
windows   338,611 rows    0.5 s  largest worker 259 MB

Sending the frame means four copies of 338,611 geometries pickled to WKB, pushed through a pipe and rebuilt β€” and the work being done with them is len(). Reading windows means each worker loads only its own quarter.

Sometimes the answer is not parallelism

import time
import geopandas as gpd
import numpy as np

gdf = gpd.read_file("data/parcels.gpkg").to_crs(27700)

t0 = time.perf_counter()
areas_loop = gdf.geometry.apply(lambda g: g.area)          # Python per feature
loop_s = time.perf_counter() - t0

t0 = time.perf_counter()
areas_vec = gdf.geometry.area                              # vectorised in GEOS
vec_s = time.perf_counter() - t0

print(f"apply      : {loop_s:6.2f} s")
print(f"vectorised : {vec_s:6.2f} s  ({loop_s/vec_s:.0f}Γ— faster, no parallelism at all)")
apply      :   0.45 s
vectorised :   0.02 s  (24Γ— faster, no parallelism at all)

Vectorising usually beats parallelising a slow implementation, and it is simpler. Reach for parallelism after the obvious single-threaded wins, not before.

And sometimes the answer is a database

import geopandas as gpd
from sqlalchemy import create_engine

engine = create_engine("postgresql+psycopg://gis@db.internal/gis")

# PostGIS runs this with its own parallel workers, against indexed data
counts = gpd.pd.read_sql("""
    SELECT w.ward_name, count(b.id) AS buildings
    FROM wards w LEFT JOIN buildings b ON ST_Within(b.geom, w.geom)
    GROUP BY w.ward_name
""", engine)

A spatial join across millions of rows is a solved problem in PostGIS, with indexes and a parallel query planner. Moving the data to Python to parallelise it by hand is usually the slower path.

Scaling tests on Birmingham's parcels

Telling the two parallelism traps apart by how the run behaves. more workers, and it got worsewhat you seewhich trapEight workers, no fasterOversubscription β€” native threadsΓ— workersSlower than running it seriallyTransfer β€” the frame is pickledper taskMemory climbs until it diesEvery worker received its own copySend paths, pin native threads to 1 in an initialiser, and scale-test before committing spatialworkflow.io
Example 2 is the initialiser; Example 3 is the scaling test that would have shown all three.

The data used on this page

HM Land Registry's INSPIRE parcels for Birmingham β€” 338,611 polygons in British National Grid β€” tagged with their ONS ward, saved whole as GeoPackage and Parquet and split into one file per ward; chunks is the geometry column in eight slices. For the network test, the 96 band files of sixteen Earth Search Sentinel-2 scenes over Snowdonia, read over HTTPS.

import io
import os
import time
import zipfile
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path

import geopandas as gpd
import numpy as np
import pyogrio
import requests

pyogrio.set_gdal_config_options({"GML_DOWNLOAD_SCHEMA": "NO"})
Path("data/wards").mkdir(parents=True, exist_ok=True)
if not Path("data/parcels.parquet").exists():
    reply = requests.Session().get("https://use-land-property-data.service.gov.uk/datasets/inspire/download/"
                                   "Birmingham.zip", timeout=600)
    reply.raise_for_status()
    zipfile.ZipFile(io.BytesIO(reply.content)).extract("Land_Registry_Cadastral_Parcels.gml", "downloads")
    parcels = gpd.read_file("downloads/Land_Registry_Cadastral_Parcels.gml")[["INSPIREID", "geometry"]]
    wards = gpd.read_file(io.BytesIO(requests.get(
        "https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/"
        "Wards_December_2024_Boundaries_UK_BGC/FeatureServer/0/query",
        params={"where": "LAD24NM='Birmingham'", "outFields": "WD24CD,WD24NM", "outSR": 27700, "f": "geojson"},
        timeout=120).content))
    centres = parcels.assign(geometry=parcels.representative_point())
    parcels["ward"] = gpd.sjoin(centres, wards, predicate="within", how="left")["WD24NM"].values
    parcels.to_file("data/parcels.gpkg")
    parcels.to_parquet("data/parcels.parquet")
    for ward, part in parcels.groupby("ward"):
        part.to_file(f"data/wards/{ward.replace(' ', '_').replace('&', 'and')}.gpkg")

parcels = gpd.read_parquet("data/parcels.parquet")
edges = np.linspace(0, len(parcels), 9).astype(int)
chunks = [parcels.geometry.iloc[a:b] for a, b in zip(edges[:-1], edges[1:])]

items = requests.get("https://earth-search.aws.element84.com/v1/search", timeout=60, params={
    "collections": "sentinel-2-l2a", "limit": 16, "bbox": "-4.1,53.0,-4.0,53.1",
    "datetime": "2025-06-01T00:00:00Z/2025-09-30T23:59:59Z"}).json()["features"]
remote_rasters = [f"/vsicurl/{item['assets'][band]['href']}" for item in items
                  for band in ("blue", "green", "red", "nir", "swir16", "swir22")]
print(f"{len(parcels):,} parcels, {parcels['ward'].nunique()} ward files; {len(remote_rasters)} remote rasters; "
      f"{os.cpu_count()} CPUs, load {os.getloadavg()[0]:.1f}")
338,611 parcels, 69 ward files; 96 remote rasters; 24 CPUs, load 0.3

Example 1: pick the executor from the workload

A tempting rule is to time one item and compare CPU time with wall time. Both kinds of CPU-bound work pass that test, though β€” and only one of them scales with threads:

def cpu_ratio(fn, item):
    t0, c0 = time.perf_counter(), time.process_time()
    fn(item)
    wall, cpu = time.perf_counter() - t0, time.process_time() - c0
    return cpu / wall

def thread_speedup(fn, items, workers=4):
    t0 = time.perf_counter()
    for item in items[:workers]:
        fn(item)
    serial = time.perf_counter() - t0
    t0 = time.perf_counter()
    with ThreadPoolExecutor(workers) as pool:
        list(pool.map(fn, items[:workers]))
    return serial / (time.perf_counter() - t0)

for label, fn in [("buffer (GEOS)", buffer_chunk), ("apply (Python)", area_by_loop)]:
    print(f"{label:15} cpu/wall {cpu_ratio(fn, chunks[0]):.2f}   4-thread speed-up {thread_speedup(fn, chunks):.1f}Γ—")
buffer (GEOS)   cpu/wall 1.00   4-thread speed-up 2.8Γ—
apply (Python)  cpu/wall 1.00   4-thread speed-up 0.4Γ—

Both are CPU-bound by the ratio; only the GEOS work speeds up on threads. So decide from the thing you care about: try two or four threads on a few items, and use threads if they help and processes if they do not. It costs a few extra calls and it measures the answer directly.

Example 2: worker initialiser that tames native threads

%%writefile init_workers.py
from concurrent.futures import ProcessPoolExecutor
import multiprocessing as mp
import os

def init_worker():
    for var in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS", "GDAL_NUM_THREADS"):
        os.environ[var] = "1"

def report(_):
    return os.getpid(), os.environ.get("OMP_NUM_THREADS")

if __name__ == "__main__":
    with ProcessPoolExecutor(max_workers=4, initializer=init_worker) as pool:
        seen = set(pool.map(report, range(40)))
    print(f"{len({pid for pid, _ in seen})} workers, OMP_NUM_THREADS in each: {sorted({v for _, v in seen})}")
    with mp.Pool(4, initializer=init_worker, maxtasksperchild=10) as pool:   # recycling: use Pool
        pids = pool.map(report, range(40), chunksize=1)
    print(f"Pool with maxtasksperchild=10: 40 tasks in {len({pid for pid, _ in pids})} worker processes")
print(subprocess.run([sys.executable, "init_workers.py"], capture_output=True, text=True, timeout=120).stdout)
2 workers, OMP_NUM_THREADS in each: ['1']
Pool with maxtasksperchild=10: 40 tasks in 4 worker processes

Recycling workers caps any per-worker memory growth. Do it with multiprocessing.Pool(maxtasksperchild=...): the ProcessPoolExecutor(max_tasks_per_child=...) equivalent deadlocked on Python 3.14.4 as soon as a worker needed replacing, as the parallel batch fix page measures.

Example 3: a scaling test before committing

def scaling_test(fn, items, max_workers=8):
    baseline = None
    for n in [1, 2, 4, max_workers]:
        t0 = time.perf_counter()
        with ThreadPoolExecutor(max_workers=n) as pool:
            list(pool.map(fn, items))
        elapsed = time.perf_counter() - t0
        baseline = baseline or elapsed
        print(f"{n:>2} workers: {elapsed:6.2f} s  speed-up {baseline/elapsed:4.1f}Γ—  "
              f"efficiency {baseline/elapsed/n:4.0%}")

scaling_test(buffer_chunk, chunks)
 1 workers:   6.84 s  speed-up  1.0Γ—  efficiency 100%
 2 workers:   3.36 s  speed-up  2.0Γ—  efficiency 102%
 4 workers:   2.08 s  speed-up  3.3Γ—  efficiency  82%
 8 workers:   1.25 s  speed-up  5.5Γ—  efficiency  68%

Efficiency below about 50% means the parallelism is not paying for itself β€” usually contention, oversubscription or transfer cost.

Example 4: Dask, when the dataset does not fit

import dask_geopandas as dgpd

ddf = dgpd.read_parquet("data/parcels.parquet", npartitions=16)
ddf = ddf.to_crs(27700)
ddf["area_m2"] = ddf.geometry.area

result = ddf.groupby("ward")["area_m2"].sum().compute()
print((result / 1e4).round(0).sort_values(ascending=False).head(5).to_string())
ward
Sutton Walmley & Minworth    1595.0
Sutton Roughley               830.0
Perry Barr                    694.0
Edgbaston                     668.0
Bartley Green                 520.0

dask-geopandas partitions the data, builds a task graph and runs it across threads or processes. It is the right tool when the dataset exceeds memory and the operations are the standard ones; hand-rolled parallelism remains simpler for whole-file batches. Here the whole thing fits in memory, so this only shows the shape of the code.

What the GIL protects, and what parallelism costs

Two ideas explain almost every parallel-GIS outcome: what the GIL protects, and what parallelism costs.

Largest worker memory when four processes are sent the whole 338,611-parcel frame, against reading their own window of it. largest worker, 4 processes, 338,611 parcelssend the frame to each561 MB β€” 2.6 ssend a path + window259 MB β€” 0.5 s0300 MB600 MBSend paths, not data β€” that single rule removes most of the cost spatialworkflow.io
Measured on this page: processes buy independence and pay for it in memory and transfer.

The GIL serialises the interpreter, not the machine. Any C extension that is not touching Python objects can release it, and the geospatial stack does so extensively: Shapely 2 around GEOS calls, GDAL during I/O and format decoding, NumPy for array arithmetic. Consequently a workload dominated by buffer, intersection, to_crs or file reading really does scale with threads, which is the opposite of the folk wisdom about Python. A workload dominated by per-feature Python β€” apply, dict building, string handling β€” does not, because that code holds the lock the entire time.

Processes sidestep the lock by having one interpreter each, and they pay for it three times: start-up (tens to hundreds of milliseconds each, more with spawn), memory (each worker holds its own copy of everything it touches), and transfer (arguments and results are pickled). For file-sized units of work those costs vanish into the noise. For fine-grained work they dominate, which is exactly why the naΓ―ve ProcessPoolExecutor version of a chunked geometry operation can be slower than the serial loop.

Oversubscription is the third factor and the least obvious. GDAL, BLAS and PROJ each maintain their own thread pools sized to the machine, so four processes can quietly become thirty-two threads. Cores then spend their time switching contexts and invalidating caches. Setting OMP_NUM_THREADS=1 and its siblings inside workers is not a micro-optimisation; it is often the difference between a speed-up and a slow-down.

Which leads to the practical ordering. First make the single-threaded version fast: vectorise instead of looping, narrow the read with columns= and where=, use the spatial index. Then, if it is still too slow, measure the CPU-to-wall ratio to see what you are waiting for. Use threads for I/O and GEOS-heavy work, processes for whole-file batches, a database for large joins and aggregations, and Dask when the data does not fit at all. And measure the result β€” parallel code that is not faster is just more complicated.

Fork, spawn and free-threaded Python

  • fork is unsafe with GDAL and GEOS: Native libraries can hold locks owned by threads that do not survive the fork. Use spawn.
  • The __main__ guard is mandatory: With spawn, children re-import your module; without the guard they re-run it.
  • Free-threaded Python (3.13t+): Experimental builds remove the GIL. Check that every geospatial wheel you depend on is built for it before planning around it.
  • asyncio does not help CPU work: It is a concurrency model for I/O; geometry operations still block the loop.
  • Threads and PyQGIS do not mix: Qt objects belong to their creating thread. Use one process per task instead.
  • Shared memory is possible: multiprocessing.shared_memory avoids copying large NumPy arrays, but geometry arrays need re-wrapping β€” usually not worth it.
  • Reading is parallel-safe, writing usually is not: Several processes appending to one GeoPackage lost rows without an error in the parallel batch fix page's test.

FAQ

Does the GIL make threads useless in Python GIS?

No. GEOS, GDAL and NumPy release the GIL while they work, so geometry operations and file I/O do scale with threads. Only per-feature Python code is serialised.

When should I use processes instead of threads?

When the work is dominated by Python-level code, or when each unit is a whole file. Processes cost start-up, memory and pickling, so the unit of work needs to be large enough to absorb that.

Why did parallel processing make my job slower?

Most often oversubscription β€” each worker starting its own native thread pool β€” or the cost of pickling GeoDataFrames between processes. Set OMP_NUM_THREADS=1 in workers and pass paths, not data.

How many workers should I use?

For CPU work, about the number of physical cores, and check that memory allows it. For I/O work, more than the core count is fine β€” 16 to 32 threads is common for reading many small files.

Is asyncio useful for GIS work?

Only for network I/O β€” fetching many tiles or API responses. It does nothing for geometry computation, which blocks the event loop.

What about Dask?

dask-geopandas is the right tool when the dataset does not fit in memory and you want the standard operations parallelised for you. For whole-file batches, a process pool is simpler.

How do I know whether my code is CPU-bound?

Compare time.process_time() with time.perf_counter() over the same call. A ratio near 1 means CPU-bound; much less means it is waiting on I/O.