Batch GIS Job Slows Down and Runs Out of Memory: How to Fix It
Problem statement
The first fifty files fly past. By file two hundred each one takes four times as long, and somewhere after that the process dies:
[198/640] tiles_198.gpkg 12.4s
[199/640] tiles_199.gpkg 13.1s
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.
Common causes:
- results are appended to a list and concatenated only at the end
- a cache, dictionary, or
functools.lru_cachekeyed 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:
- write each result to disk inside the loop instead of collecting frames in a list
- delete large locals at the end of each iteration and let refcounting free them
- close every matplotlib figure with
plt.close(fig)β never rely onplt.close()alone - read only the columns and rows you need (
columns=,bbox=,where=) - 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
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
Step-by-step solution
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 % 10 == 0:
rss = proc.memory_info().rss / 1_048_576
print(f"[{i}/{len(files)}] rss={rss:.0f} MB", flush=True)
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()
# ... run 50 iterations ...
snapshot_b = tracemalloc.take_snapshot()
for stat in snapshot_b.compare_to(snapshot_a, "lineno")[:10]:
print(stat)
Stream results to disk instead of collecting them
The single most common cause is a list that holds every result until the end.
# leaks: every frame stays resident until the concat
frames = []
for path in files:
frames.append(gpd.read_file(path))
merged = pd.concat(frames)
# streams: one frame resident at a time
for path in files:
gdf = gpd.read_file(path)
gdf.to_file("data/out/merged.gpkg", layer="all", driver="GPKG", mode="a")
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
# only the columns you use β attribute tables are often larger than the geometry
gdf = gpd.read_file(path, columns=["id", "class", "geometry"])
# only the area you care about
gdf = gpd.read_file(path, bbox=(-3.1, 55.9, -3.0, 56.0))
# only matching rows, pushed down to the driver (pyogrio engine)
gdf = gpd.read_file(path, where="class = 'residential'")
# a fixed slice, for testing the loop cheaply
gdf = gpd.read_file(path, rows=1000)
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.
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
for path in files:
fig, ax = plt.subplots(figsize=(8, 8))
gpd.read_file(path).plot(ax=ax)
fig.savefig(out / f"{path.stem}.png", dpi=150)
plt.close(fig) # without this, every figure stays in pyplot's registry
The same applies to rasters and databases β use context managers so they close even when a step raises:
import rasterio
with rasterio.open(path) as src:
band = src.read(1) # closed on exit, even on exception
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.
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.
from concurrent.futures import ProcessPoolExecutor
with ProcessPoolExecutor(max_workers=4, max_tasks_per_child=25) as pool:
list(pool.map(process_one, files))
max_tasks_per_child requires Python 3.11+; on older versions, multiprocessing.Pool(maxtasksperchild=25) does the same job.
Code examples
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 % 25 == 0:
rss = proc.memory_info().rss / 1_048_576
print(f"[{i}/{len(files)}] {total} features, rss={rss:.0f} MB", flush=True)
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 = "data/raw/national_parcels.gpkg"
info = pyogrio.read_info(path)
total = info["features"]
CHUNK = 100_000
for start in range(0, total, CHUNK):
gdf = gpd.read_file(path, engine="pyogrio", skip_features=start, max_features=CHUNK)
gdf["area_m2"] = gdf.to_crs(3857).area
mode = "w" if start == 0 else "a"
gdf.to_file("data/out/parcels_area.gpkg", layer="parcels", driver="GPKG", mode=mode)
print(f"{start + len(gdf)}/{total}")
del gdf
Example 3: raster batches with windowed reads
import rasterio
from rasterio.windows import Window
with rasterio.open("data/raw/dem.tif") as src:
profile = src.profile
with rasterio.open("data/out/dem_scaled.tif", "w", **profile) as dst:
for ji, window in src.block_windows(1):
block = src.read(1, window=window)
dst.write((block * 0.3048).astype(profile["dtype"]), 1, window=window)
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[:5]:
print(f"{stat.size_diff/1_048_576:.1f} MB in {stat.count_diff} blocks")
for line in stat.traceback.format():
print(" ", line)
break
Comparing two snapshots taken mid-run points at the exact line that is still holding memory.
Explanation
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.
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.
Edge cases or notes
Killedwith no traceback is the OOM killer: Checkdmesg -T | grep -i oomon Linux to confirm. Python never saw the error, so noexceptcould 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_workersagainst 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.
Internal links
- Fixing Memory Errors in GeoPandas When Working with Large Files
- How to Speed Up GeoPandas: Tips for Large Datasets
- How to Batch Process a Folder of GIS Files in Python: The Complete Workflow
- How to Speed Up Batch GIS Jobs with Parallel Processing in Python
- How to Batch Process Rasters with Rasterio in Python
- Parallel GIS Batch Job Hangs or Crashes with multiprocessing: How to Fix It
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 workers with max_tasks_per_child.
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.