Batch GIS Job Slows Down and Runs Out of Memory: How to Fix It

On this page

Problem statement

The first files fly past, and the process grows with every one of them. This is a real run β€” a loop that draws each of 60 countries' provinces to a PNG and never closes the figure (the full test is under step 5):

close=False [20/60] rss +  99 MB, 20 figures open
close=False [40/60] rss + 188 MB, 40 figures open
close=False [60/60] rss + 300 MB, 60 figures open

Nothing has failed yet. At five megabytes a figure, a few thousand files is more memory than most machines have, and then the process dies β€” on Linux with a bare Killed.

Killed with no traceback is the Linux OOM killer. On Windows you get a MemoryError, and in a notebook the kernel simply restarts. Either way, the important detail is that no single file is too big β€” the accumulation is. Memory that should have been released after each iteration is still referenced somewhere.

Where the accumulation usually comes from:

  • results are appended to a list and concatenated only at the end
  • a cache, dictionary, or functools.lru_cache keyed on file path grows without bound
  • each iteration builds a spatial index or a plot that is never closed
  • exception objects hold a reference to the frame that raised them, keeping large locals alive
  • worker processes in a pool are never recycled, so each accumulates its own leak
  • the loop reads whole files when it only needs a bounding box or a few columns

The distinguishing symptom is the slowdown before the crash: as the resident set grows, the allocator and the OS page cache work harder, and garbage collection has more to scan. Constant memory means constant speed; growing memory shows up as a curve.

Quick answer

To stop a batch from growing without bound:

  1. write each result to disk inside the loop instead of collecting frames in a list
  2. delete large locals at the end of each iteration and let refcounting free them
  3. close every matplotlib figure with plt.close(fig) β€” never rely on plt.close() alone
  4. read only the columns and rows you need (columns=, bbox=, where=)
  5. measure per-iteration memory so you can prove the leak is gone
from pathlib import Path
import gc
import geopandas as gpd

out = Path("data/out"); out.mkdir(parents=True, exist_ok=True)

for i, path in enumerate(sorted(Path("data/raw").glob("*.gpkg")), start=1):
    gdf = gpd.read_file(path, columns=["id", "class", "geometry"])
    result = gdf.dissolve(by="class", as_index=False)
    result.to_file(out / f"{path.stem}_dissolved.gpkg", driver="GPKG")
    del gdf, result                   # drop the references now, not at loop end
    if i % 50 == 0:
        gc.collect()                  # collect cycles occasionally, not every iteration
print(f"{i} files, rss {rss():.0f} MB")
251 files, rss 280 MB

Writing inside the loop is the change that matters. A list of 640 GeoDataFrames is 640 datasets in RAM at once; a file on disk is zero.

Where the memory goes

Measured growth in resident memory for the patterns on this page, over 251 and 60 real files. RSS growth, measured251 frames kept, concat+26 MB… after del+25 MB β€” not given back251 files streamed+2 MB60 figures, no close+300 MB60 figures, closed+60 MB0100 MB200 MB300 MBNatural Earth provinces, one GeoPackage per country; the figures are the worst leak spatialworkflow.io
Measured on this page’s files: accumulating costs memory that del does not return, and an unclosed figure costs five megabytes each.

Measure, stream, and let go of each iteration

Six ways a batch leaks memory, each with its one-line fix. six leaks, six one-line fixesWhat holds the memoryFixFrames appended to a listWrite each result inside the loopFigures never closedplt.close(fig) after every saveException objects storedKeep the message string onlyWhole file read for two columnscolumns=, bbox=, where= at readtimeLast frame alive during the nextdel, or move the body into afunctionPool workers never recycledmax_tasks_per_child=25Reading less beats freeing more β€” the excluded rows never become objects spatialworkflow.io
Five ways a batch leaks, and the one-line fix for each.

Measure before you guess

Add a memory reading per iteration. Two lines will tell you whether you have a leak or a single oversized file.

import os, psutil
proc = psutil.Process(os.getpid())

for i, path in enumerate(files, start=1):
    process_one(path)
    if i % 50 == 0:
        rss_mb = proc.memory_info().rss / 1_048_576
        print(f"[{i}/{len(files)}] rss={rss_mb:.0f} MB", flush=True)
[50/251] rss=280 MB
[100/251] rss=280 MB
[150/251] rss=280 MB
[200/251] rss=289 MB
[250/251] rss=292 MB

A flat line with occasional spikes means one big file. A staircase that never comes down means accumulation, and the slope tells you how many iterations you have left before the kill.

Without psutil, tracemalloc gives you the Python-side allocation totals and, crucially, where they were allocated:

import tracemalloc
tracemalloc.start()
snapshot_a = tracemalloc.take_snapshot()
kept = [gpd.read_file(path) for path in files[:50]]         # 50 iterations of a loop that keeps its frames
snapshot_b = tracemalloc.take_snapshot()
for stat in snapshot_b.compare_to(snapshot_a, "lineno")[:5]:
    frame = stat.traceback[0]
    print(f"{stat.size_diff / 1_048_576:6.1f} MB  {frame.filename.split('site-packages/')[-1]}:{frame.lineno}")
tracemalloc.stop()
del kept
   9.4 MB  pyogrio/raw.py:200
   0.8 MB  pandas/core/internals/blocks.py:650
   0.6 MB  pandas/core/construction.py:616
   0.4 MB  pandas/core/internals/blocks.py:645
   0.3 MB  pandas/core/internals/managers.py:2431

Stream results to disk instead of collecting them

The single most common cause is a list that holds every result until the end.

import pandas as pd

# leaks: every frame stays resident until the concat
start = rss()
frames = []
for path in files:
    frames.append(gpd.read_file(path))
merged = pd.concat(frames)
print(f"accumulate + concat: rss +{rss() - start:.0f} MB")
del frames, merged
print(f"after del:           rss +{rss() - start:.0f} MB")

# streams: one frame resident at a time
start = rss()
for path in files:
    gdf = gpd.read_file(path)
    gdf.to_file("data/out/merged.gpkg", layer="all", driver="GPKG", mode="a")
print(f"stream:              rss +{rss() - start:.0f} MB")
accumulate + concat: rss +26 MB
after del:           rss +26 MB
stream:              rss +1 MB

If you genuinely need one merged dataset, append into a GeoPackage layer as above, or write per-file Parquet and let a query engine read the folder as one table. Both keep peak memory at the size of the largest single input.

Read less in the first place

Most batch steps use a fraction of what they load. Every reader supports narrowing the read.

import geopandas as gpd

path = raw / "GBR.gpkg"
print("everything:  ", gpd.read_file(path).shape)

# only the columns you use β€” attribute tables are often larger than the geometry
gdf = gpd.read_file(path, columns=["id", "class", "geometry"])
print("3 columns:   ", gdf.shape)

# only the area you care about
gdf = gpd.read_file(path, bbox=(-3.1, 55.9, -3.0, 56.0))
print("bbox:        ", gdf.shape, gdf["name"].tolist())

# only matching rows, pushed down to the driver (pyogrio engine)
gdf = gpd.read_file(path, where="class = 'Unitary Authority'")
print("where:       ", gdf.shape)

# a fixed slice, for testing the loop cheaply
gdf = gpd.read_file(path, rows=10)
print("rows=10:     ", gdf.shape)
everything:   (232, 122)
3 columns:    (232, 3)
bbox:         (3, 122) ['Midlothian', 'Edinburgh', 'East Lothian']
where:        (50, 122)
rows=10:      (10, 122)

columns= and where= are evaluated by the driver, so the rows never enter Python at all β€” this is far more effective than filtering after the read.

What each read_file option returned for Britain’s 232 Natural Earth provinces. read less: GBR.gpkgreadrowscolumnseverything232122columns=[3 names]2323bbox around Edinburgh3122where="class = 'Unitary Authority'"50122rows=1010122Every option is applied by the driver, before pandas sees a row spatialworkflow.io
The attribute table is most of a Natural Earth file: asking for 3 of 122 columns is the biggest saving here.

Drop references at the end of each iteration

CPython frees an object as soon as its reference count hits zero. A variable assigned in the loop body stays bound until it is reassigned on the next iteration, which means the previous frame is alive while the next one is being built β€” a 2Γ— peak.

for path in files:
    gdf = gpd.read_file(path)
    result = expensive_step(gdf)
    result.to_file(out / f"{path.stem}.gpkg", driver="GPKG")
    del gdf, result          # peak is now one frame, not two

Better still, put the body in a function. Locals disappear when the function returns, and the intent is clearer than a del.

def process_one(path: Path) -> None:
    gdf = gpd.read_file(path)
    expensive_step(gdf).to_file(out / f"{path.stem}.gpkg", driver="GPKG")

for path in files:
    process_one(path)

Close figures, datasets and connections

Anything with an open/close pair holds native memory that Python's garbage collector cannot see.

import matplotlib
matplotlib.use("Agg")            # no GUI backend in a batch job
import matplotlib.pyplot as plt

Path("data/png").mkdir(exist_ok=True)
for close in (True, False):
    start = rss()
    for i, path in enumerate(files[:60], start=1):
        fig, ax = plt.subplots(figsize=(8, 8))
        gpd.read_file(path).plot(ax=ax)
        fig.savefig(f"data/png/{path.stem}.png", dpi=150)
        if close:
            plt.close(fig)       # without this, every figure stays in pyplot's registry
        if i % 20 == 0:
            print(f"close={close!s:5} [{i}/60] rss +{rss() - start:4.0f} MB, {len(plt.get_fignums())} figures open")
    plt.close("all")
close=True  [20/60] rss +  78 MB, 0 figures open
close=True  [40/60] rss + 189 MB, 0 figures open
close=True  [60/60] rss +  60 MB, 0 figures open
close=False [20/60] rss +  99 MB, 20 figures open
close=False [40/60] rss + 188 MB, 40 figures open
close=False [60/60] rss + 300 MB, 60 figures open

Without close, all 60 figures stay open and memory only climbs β€” 300 MB by the end, five megabytes a figure. With it, memory still swings, up to 189 MB in mid-run, because the allocator keeps freed memory for reuse rather than handing it back; but nothing is held, and it ends where the next file can reuse it.

The same applies to rasters and databases β€” use context managers so they close even when a step raises:

import rasterio

with rasterio.open(dem) as src:
    band = src.read(1)           # closed on exit, even on exception
print(band.shape, "read; dataset closed:", src.closed)
(1200, 800) read; dataset closed: True

Hold onto error messages, not exception objects

A caught exception keeps a traceback, and a traceback keeps every frame's locals alive β€” including the 2 GB GeoDataFrame that caused the problem.

# leaks the failing frame's locals into the results list
except Exception as exc:
    failures.append(exc)

# keeps only text
except Exception as exc:
    failures.append(f"{path.name}: {type(exc).__name__}: {exc}")

Python clears the exc name at the end of the except block for this reason, but storing the object elsewhere defeats that.

Two standard-library ways to recycle pool workers, run on Python 3.14.4 with 120 files and four workers. recycling workers on Python 3.14.4executor, max_tasks_per_childβœ•100 tasks done: 4 workers Γ— 25βœ•no replacement worker startsβœ•the pool waits foreverβœ•forkserver and spawn alikemp.Pool, maxtasksperchildβœ“all 120 files doneβœ“in 5.7 sβœ“or: a fresh executor per 100 filesβœ“same bound on each worker’s lifeMeasured on this page’s files; check your Python before relying on max_tasks_per_child spatialworkflow.io
The executor option deadlocked after exactly four workers’ worth of tasks; the older Pool, or one executor per chunk, recycles without it.

Recycle pool workers

In a ProcessPoolExecutor, a leak inside the worker function accumulates for the life of the worker. Recycling processes after a fixed number of tasks caps the damage.

%%writefile jobs.py
from pathlib import Path
import geopandas as gpd

def process_one(path):
    gdf = gpd.read_file(path, columns=["id", "class", "geometry"])
    gdf.dissolve(by="class", as_index=False).to_file(Path("data/out") / f"{Path(path).stem}.gpkg", driver="GPKG")
from concurrent.futures import ProcessPoolExecutor
import jobs                      # workers import their function; a notebook cannot be imported

# a fresh pool per 100 files: every worker's memory is returned when its pool closes
for start in range(0, len(files), 100):
    with ProcessPoolExecutor(max_workers=4) as pool:
        list(pool.map(jobs.process_one, files[start:start + 100]))
print(f"{len(files)} files through {-(-len(files) // 100)} pools; "
      f"{sum((out / f.name).exists() for f in files)} outputs")
251 files through 3 pools; 251 outputs

ProcessPoolExecutor(max_tasks_per_child=25) is the standard-library way to recycle workers, and on Python 3.14.4 it did not work here: with four workers and 120 files it completed exactly 100 tasks β€” four workers Γ— 25 β€” and then waited indefinitely, because no replacement worker ever started. That happened with both the forkserver and spawn start methods (it refuses fork outright). A pool per chunk gets the same effect β€” workers that live for a bounded amount of work β€” with nothing to deadlock.

The older multiprocessing.Pool does recycle correctly on the same Python, and takes a function and an iterable just as pool.map does:

import multiprocessing as mp
import time

start = time.perf_counter()
with mp.Pool(4, maxtasksperchild=25) as pool:
    pool.map(jobs.process_one, files[:120])
print(f"Pool(maxtasksperchild=25): 120 files in {time.perf_counter() - start:.1f} s")
Pool(maxtasksperchild=25): 120 files in 5.7 s

Constant memory across 251 province files

The data used on this page

Natural Earth's 1:10 million states and provinces (public domain) β€” 4,596 polygons with 122 attribute columns β€” split into one GeoPackage per country, as a batch would receive them: 251 files in data/raw/, with ne_id renamed id and the English unit type (Province, District, State …) as class. The chunked-read example uses the 56,600-feature Natural Earth roads layer, and the raster example one Copernicus GLO-90 DEM tile. Memory is measured as the process's resident set size (RSS) with psutil.

import io
import os
import zipfile
from pathlib import Path

import geopandas as gpd
import psutil
import requests

def natural_earth(theme, name, folder=Path("data/src")):
    if not (folder / f"{name}.shp").exists():
        reply = requests.get(f"https://naciscdn.org/naturalearth/10m/{theme}/{name}.zip", timeout=300)
        reply.raise_for_status()
        zipfile.ZipFile(io.BytesIO(reply.content)).extractall(folder)
    return folder / f"{name}.shp"

provinces = gpd.read_file(natural_earth("cultural", "ne_10m_admin_1_states_provinces"))
raw, out = Path("data/raw"), Path("data/out")
raw.mkdir(parents=True, exist_ok=True)
out.mkdir(parents=True, exist_ok=True)
for country, rows in provinces.groupby("adm0_a3"):
    if not (raw / f"{country}.gpkg").exists():
        rows.rename(columns={"ne_id": "id", "type_en": "class"}).to_file(raw / f"{country}.gpkg", driver="GPKG")
roads = natural_earth("cultural", "ne_10m_roads")

dem = Path("data/src/dem.tif")                     # Copernicus GLO-90, the Lake District
if not dem.exists():
    name = "Copernicus_DSM_COG_30_N54_00_W004_00_DEM"
    dem.write_bytes(requests.get(f"https://copernicus-dem-90m.s3.amazonaws.com/{name}/{name}.tif", timeout=120).content)

files = sorted(raw.glob("*.gpkg"))
proc = psutil.Process(os.getpid())
rss = lambda: proc.memory_info().rss / 1_048_576

def expensive_step(gdf):
    return gdf.dissolve(by="class", as_index=False)

def process_one(path):
    gdf = gpd.read_file(path, columns=["id", "class", "geometry"])
    expensive_step(gdf).to_file(out / f"{Path(path).stem}.gpkg", driver="GPKG")

print(f"{len(provinces):,} provinces, {provinces.shape[1]} columns -> {len(files)} files, "
      f"{sum(f.stat().st_size for f in files) / 1e6:.1f} MB")
4,596 provinces, 122 columns -> 251 files, 53.7 MB

Example 1: a chunked, constant-memory batch

from pathlib import Path
import geopandas as gpd
import psutil, os

SRC, OUT = Path("data/raw"), Path("data/out")
OUT.mkdir(parents=True, exist_ok=True)
proc = psutil.Process(os.getpid())

def process_one(path: Path) -> int:
    gdf = gpd.read_file(path, columns=["id", "class", "geometry"])
    out = gdf[gdf.geometry.notna() & gdf.geometry.is_valid]
    out.to_file(OUT / f"{path.stem}.gpkg", driver="GPKG")
    return len(out)

total = 0
files = sorted(SRC.glob("*.gpkg"))
for i, path in enumerate(files, start=1):
    total += process_one(path)
    if i % 50 == 0:
        rss_mb = proc.memory_info().rss / 1_048_576
        print(f"[{i}/{len(files)}] {total} features, rss={rss_mb:.0f} MB", flush=True)
[50/251] 748 features, rss=781 MB
[100/251] 1691 features, rss=781 MB
[150/251] 2734 features, rss=785 MB
[200/251] 3536 features, rss=796 MB
[250/251] 4585 features, rss=796 MB

Example 2: process a huge layer in row chunks

When a single file is the problem, read it in slices with pyogrio rather than all at once.

import pyogrio
import geopandas as gpd

path = roads                                   # 56,600 Natural Earth roads
info = pyogrio.read_info(path)
total = info["features"]
CHUNK = 10_000

start_rss = rss()
for start in range(0, total, CHUNK):
    gdf = gpd.read_file(path, engine="pyogrio", skip_features=start, max_features=CHUNK)
    gdf["length_m"] = gdf.to_crs(8857).length
    mode = "w" if start == 0 else "a"
    gdf.to_file("data/out/roads_length.gpkg", layer="roads", driver="GPKG", mode=mode)
    print(f"{start + len(gdf)}/{total}  rss +{rss() - start_rss:.0f} MB")
    del gdf
10000/56600  rss +28 MB
20000/56600  rss +34 MB
30000/56600  rss +38 MB
40000/56600  rss +48 MB
50000/56600  rss +42 MB
56600/56600  rss +40 MB

Example 3: raster batches with windowed reads

import rasterio
from rasterio.windows import Window

with rasterio.open(dem) as src:
    profile = src.profile
    print("internal blocks:", src.block_shapes, "for", src.width, "Γ—", src.height)
    # one block holds the whole tile, so block_windows would read it all at once: use row strips
    strips = [Window(0, row, src.width, min(100, src.height - row)) for row in range(0, src.height, 100)]
    with rasterio.open("data/out/dem_ft.tif", "w", **profile) as dst:
        for window in strips:
            block = src.read(1, window=window)
            dst.write((block * 3.28084).astype(profile["dtype"]), 1, window=window)   # metres to feet
print(len(strips), "strips of at most", 100 * src.width, "cells")
internal blocks: [(2048, 2048)] for 800 Γ— 1200
12 strips of at most 80000 cells

Block-windowed I/O keeps a single tile in memory regardless of the raster's size, which is the raster equivalent of streaming rows.

Example 4: find the leak with tracemalloc

import tracemalloc
from pathlib import Path

tracemalloc.start(10)
files = sorted(Path("data/raw").glob("*.gpkg"))

for i, path in enumerate(files, start=1):
    process_one(path)
    if i == 20:
        base = tracemalloc.take_snapshot()
    if i == 60:
        diff = tracemalloc.take_snapshot().compare_to(base, "traceback")
        for stat in diff[:3]:
            frame = stat.traceback[-1]                     # the most recent frame
            where = frame.filename.split("site-packages/")[-1]
            print(f"{stat.size_diff/1_048_576:.2f} MB in {stat.count_diff} blocks, "
                  f"{Path(where).name if where.startswith('/') else where}:{frame.lineno}")
        break
tracemalloc.stop()
4.62 MB in 1947 blocks, pyogrio/raw.py:200
0.01 MB in 71 blocks, shapely/io.py:200
0.01 MB in 200 blocks, __init__.py:297

Comparing two snapshots taken mid-run points at the exact line that is still holding memory.

What keeps memory reachable in a loop

Peak memory in a loop is determined by what remains reachable at the moment of highest allocation. In an accumulating loop that is every result produced so far, plus the one being built. In a streaming loop it is one input plus one output. The file count therefore appears in the first formula and not the second β€” which is why the crash always arrives at "some large number of files" rather than on a specific file.

A constant-memory loop: read narrowed, transform, write, release, measure. flat by construction1read narrowedcolumns=, where=2transform one filepeak = one frame3write to diskGPKG or Parquet4release referencesdel, or function scope5log rss every 25 filesproof it stays flatPeak memory is one input plus one output, whatever the file count spatialworkflow.io
Read narrow, transform, write, release β€” the loop's memory profile is then flat by construction.

CPython frees objects immediately when their reference count reaches zero; the cycle collector only handles objects that reference each other. That has two practical consequences. First, gc.collect() rarely fixes a GeoDataFrame leak β€” if memory does not drop after del, something still holds a reference, and collecting cycles will not change that. Second, the things that do need explicit attention are native resources: GDAL datasets, matplotlib figures, database connections. Python's view of them is a small handle, so the interpreter feels no pressure to release the megabytes behind it.

The slowdown that precedes the crash is a useful diagnostic. Growing memory means more page faults, worse cache locality, and longer garbage-collection scans, so per-file time rises smoothly. If time per file is flat right up to the kill, the problem is more likely one enormous input than an accumulation.

Finally, reading less beats freeing more. A where= clause or a columns= list pushes the filter into GDAL, so the excluded data is never materialised as Python objects at all. Loading a 40-column parcels layer to compute one area column is the most common avoidable cost in a GIS batch.

The OOM killer and memory the OS never gets back

  • Killed with no traceback is the OOM killer: Check dmesg -T | grep -i oom on Linux to confirm. Python never saw the error, so no except could have caught it.
  • Freed memory is not always returned to the OS: The allocator may keep arenas for reuse, so RSS can plateau rather than drop. What matters is that it stops growing.
  • Notebooks keep every output alive: Out[…] and _ hold references to previous cell results. Restart the kernel before measuring, and avoid displaying large frames in a loop.
  • copy() is cheaper than you think, views are not: Slicing a GeoDataFrame can keep the parent alive through a view. Call .copy() on the slice you keep and drop the parent.
  • Parallelism multiplies peak memory: Four workers means four copies of the per-file peak. Size max_workers against available RAM, not core count.
  • Dissolve, overlay and sjoin can spike well above input size: Intermediate results in these operations are often several times the inputs. Chunk by region if a single call is the peak.

FAQ

Why does my batch die after 200 files when each file is only 50 MB?

Because something is keeping the earlier results alive β€” usually a list of frames, a cache, or stored exception objects. Fifty MB times 200 is 10 GB, which is a very ordinary way to exhaust RAM without any single file being large.

Does calling gc.collect() in the loop fix a leak?

Almost never. It collects reference cycles, but a GeoDataFrame held by a list is not a cycle β€” it is simply still referenced. Find and remove the reference; call gc.collect() occasionally at most.

Why did memory not drop after del gdf?

Either another name still refers to the object (a slice, a list entry, a closure), or the allocator kept the memory for reuse rather than returning it to the OS. Check with sys.getrefcount() or a tracemalloc snapshot diff.

How do I process one file that is too big for RAM?

Read it in slices with skip_features/max_features via pyogrio, or filter at read time with bbox=/where=. For rasters, use windowed reads over src.block_windows().

Is Killed the same as MemoryError?

No. MemoryError is raised by Python when an allocation fails and can be caught. Killed means the OS terminated the process, usually the Linux OOM killer, and no Python code runs at that point.

Should I use multiprocessing to reduce memory?

It does not reduce memory β€” each worker has its own full copy of the per-file peak, so total usage multiplies. It helps only if the leak was per-worker and you recycle the workers β€” with multiprocessing.Pool(maxtasksperchild=...), or a fresh pool per chunk.

Why do my matplotlib maps leak in a batch?

pyplot keeps a registry of every figure until it is closed. Call plt.close(fig) after each save and set the non-interactive Agg backend at the top of the script.