Batch Transforming 10k+ Shapefiles Without Memory Leaks Jump to heading
Processing legacy spatial datasets at scale requires strict resource isolation and deterministic schema enforcement. Government-scale ingestion volumes routinely trigger resident set size (RSS) growth when GDAL/OGR driver caches, lingering C-level file descriptors, and unbounded schema drift compound across sequential reads. Transforming 10,000+ shapefiles without memory leaks demands a departure from monolithic DataFrame loads and reliance on implicit Python garbage collection. This page implements the memory-stable execution model that underpins Batch Schema Processing Pipelines, the streaming stage at the heart of Automated Attribute Transformation & ETL Workflows.
The pipeline below enforces explicit teardown, chunked I/O, and schema-first validation. It holds a flat memory footprint across multi-day runs while preserving auditability: every file either lands standardized in the output directory or is quarantined with a structured error record. Field mapping reuses the deterministic rule evaluation defined in Field Renaming & Type Coercion Rules, and CRS handling defers to CRS Normalization & Sync so that reprojection logic stays in one place rather than being reinvented per batch.
Prerequisites Jump to heading
Confirm the worker environment meets these requirements before launching a full 10k-file run:
Step 1 — Pin Driver Cache & Memory Thresholds Jump to heading
GDAL/OGR maintains internal index buffers and geometry caches that persist across fiona or ogr sessions unless explicitly cleared. Set these environment variables at process initialization — before the first fiona.open — to disable aggressive caching and prevent descriptor accumulation:
# Requires: Python >= 3.10
import os
os.environ["GDAL_CACHEMAX"] = "128" # MB; hard cap on the block cache
os.environ["OGR_ENABLE_PARTIAL_REPROJECTION"] = "NO" # fail loudly instead of silently dropping
os.environ["CPL_DEBUG"] = "OFF" # suppress per-feature debug allocations
os.environ["VSI_CACHE"] = "FALSE" # no virtual file-system read cache
os.environ["GDAL_DISABLE_READDIR_ON_OPEN"] = "EMPTY_DIR" # skip sidecar directory scans
Apply strict operational thresholds before iteration begins. These are the guard rails the executor enforces in Step 3:
- Hard RSS ceiling: 2.0 GB per worker process
- Soft GC trigger: 1.5 GB sustained allocation
- Open descriptor limit: 256 concurrent file handles
- Cache flush / chunk interval: every 75 processed files
Exceeding these limits on memory-constrained CI runners or state-managed VMs triggers swap thrashing and OOM kills. Explicit garbage collection and descriptor flushing — not Python reference counting — keep the C-level allocations bounded.
Verify: confirm the caps took effect before opening any file — python -c "from osgeo import gdal; print(gdal.GetCacheMax())" should print 134217728 (128 MB in bytes), and os.environ["VSI_CACHE"] should read back as "FALSE".
Step 2 — Load the Deterministic Schema Mapping Jump to heading
Shapefiles lack native schema versioning, so field drift across directories is inevitable. Define a manifest of target field names, types, and source aliases, plus the maximum mismatch tolerance that decides whether a file is processed or quarantined. The mandatory/optional fields are:
| Field | Required | Purpose |
|---|---|---|
target_schema[].name |
mandatory | Canonical output field name written to every standardized record |
target_schema[].type |
mandatory | Coercion target — str or float in this manifest |
target_schema[].source_fields |
mandatory | Ordered alias list; the first match in the source wins |
target_schema[].transform |
optional | Per-field expression applied before type coercion |
validation.max_field_mismatch_pct |
mandatory | Reject the file if more than this share of target fields are unmatched |
validation.required_geometry |
mandatory | Geometry type the output schema and per-feature check enforce |
# schema_mapping.yaml
target_schema:
- name: parcel_id
type: str
source_fields: ["PARCEL_ID", "PID", "APN"]
- name: land_use_code
type: str
source_fields: ["LU_CODE", "LANDUSE", "ZONING"]
- name: area_sqm
type: float
source_fields: ["AREA", "SQ_METERS", "ACRES"]
transform: "lambda x: x * 4046.86 if x else None" # acres -> square metres
validation:
max_field_mismatch_pct: 15
required_geometry: "Polygon"
Parse the manifest into a lookup structure once, then validate each input file against the expected schema signature. Files failing the mismatch threshold are quarantined rather than forced through coercion that would break downstream joins. This is the same explicit rule evaluation used across Field Renaming & Type Coercion Rules, applied here at directory scale.
# Requires: Python >= 3.10, PyYAML >= 6.0
import yaml
from typing import Any
def load_schema_config(config_path: str) -> dict[str, Any]:
with open(config_path, "r", encoding="utf-8") as f:
return yaml.safe_load(f)
def validate_schema_match(src_props: dict[str, str], config: dict[str, Any]) -> bool:
"""Return True if matched target fields satisfy the mismatch threshold."""
target_fields = config["target_schema"]
source_fields = set(src_props.keys())
matched = sum(
1 for f in target_fields
if any(s in source_fields for s in f["source_fields"])
)
mismatch_pct = (1 - (matched / len(target_fields))) * 100
return mismatch_pct <= config["validation"]["max_field_mismatch_pct"]
Verify: load the manifest once and assert the contract is intact before the run — cfg = load_schema_config("schema_mapping.yaml"); assert cfg["validation"]["max_field_mismatch_pct"] == 15 and all("source_fields" in f for f in cfg["target_schema"]). A KeyError here means a mandatory directive from the table above is missing.
Step 3 — Process Files in Fixed Chunks With Explicit Teardown Jump to heading
Iterate in fixed batches of 75 — the same value as the cache-flush interval from Step 1, so a single boundary governs both throughput and memory pressure. Each batch must complete with deterministic cleanup: close every dataset handle immediately after write, invoke gc.collect() to reclaim cyclic references, and log the per-batch RSS delta so a slow leak is visible long before it becomes an OOM kill.
The per-file lifecycle below is the leak-proof core of the loop: every path — success or fault — converges on the same finally teardown, so no fiona handle ever escapes the iteration that opened it.
# Requires: Python >= 3.10, fiona >= 1.9, pyproj >= 3.6
import gc
import resource
import logging
from pathlib import Path
from typing import Any
import fiona
from fiona.transform import transform_geom
from pyproj import CRS
logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s")
logger = logging.getLogger("shapefile_etl")
TARGET_CRS = "EPSG:4326"
TARGET_CRS_OBJ = CRS.from_epsg(4326)
def get_rss_mb() -> float:
"""Return peak RSS in MB. ru_maxrss is KB on Linux, bytes on macOS."""
return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
def transform_record(feat: dict[str, Any], config: dict[str, Any]) -> dict[str, Any]:
"""Apply field mapping and type coercion to one feature's properties."""
mapped: dict[str, Any] = {}
for field in config["target_schema"]:
value = None
for src in field["source_fields"]:
if src in feat["properties"]:
value = feat["properties"][src]
break
if "transform" in field and value is not None:
value = eval(field["transform"])(value) # noqa: S307 — trusted manifest only
if field["type"] == "float" and value is not None:
try:
value = float(value)
except (ValueError, TypeError):
value = None
elif field["type"] == "str":
value = str(value) if value is not None else None
mapped[field["name"]] = value
return mapped
def process_batch(
file_batch: list[Path],
config: dict[str, Any],
output_dir: Path,
quarantine_dir: Path,
) -> None:
"""Process one chunk of shapefiles with explicit teardown per file."""
for src_file in file_batch:
try:
with fiona.open(str(src_file), "r") as src:
if not validate_schema_match(src.schema["properties"], config):
raise ValueError("Schema mismatch exceeds threshold")
if src.crs is None:
raise RuntimeError("Missing .prj file — CRS undefined")
src_crs_obj = CRS.from_user_input(src.crs)
needs_transform = not src_crs_obj.equals(TARGET_CRS_OBJ)
out_schema = {
"geometry": config["validation"]["required_geometry"],
"properties": {f["name"]: f["type"] for f in config["target_schema"]},
}
out_path = output_dir / f"{src_file.stem}_standardized.shp"
with fiona.open(
str(out_path), "w",
driver="ESRI Shapefile",
schema=out_schema,
crs=TARGET_CRS,
) as dst:
for feat in src:
geom = feat["geometry"]
if geom is None or not geom.get("coordinates"):
continue # skip empty geometries rather than writing nulls
if needs_transform:
geom = transform_geom(src.crs, TARGET_CRS, geom)
mapped_props = transform_record(feat, config)
dst.write({"type": "Feature", "geometry": geom, "properties": mapped_props})
logger.info("Completed: %s", src_file.name)
except Exception as e: # quarantine, never crash the batch
(quarantine_dir / src_file.name).write_bytes(src_file.read_bytes())
src_file.unlink()
logger.error("Quarantined %s: %s", src_file.name, e)
finally:
gc.collect() # reclaim cyclic refs left by the fiona session
rss = get_rss_mb()
if rss > 1500: # soft GC trigger from Step 1
logger.warning("High RSS (%.0f MB) — forcing second GC pass.", rss)
gc.collect()
The driver loop slices the sorted file list into 75-file chunks and logs a batch counter so a stalled run is easy to locate in the audit trail:
# Requires: Python >= 3.10
def main() -> None:
config = load_schema_config("schema_mapping.yaml")
input_dir = Path("input_shapefiles")
output_dir = Path("output_standardized")
quarantine_dir = Path("quarantine")
output_dir.mkdir(exist_ok=True)
quarantine_dir.mkdir(exist_ok=True)
files = sorted(input_dir.glob("*.shp"))
batch_size = 75
for i in range(0, len(files), batch_size):
batch = files[i : i + batch_size]
logger.info("Batch %d (%d files) — RSS %.0f MB", i // batch_size + 1, len(batch), get_rss_mb())
process_batch(batch, config, output_dir, quarantine_dir)
logger.info("Batch transformation complete: %d files processed.", len(files))
if __name__ == "__main__":
main()
Step 4 — Normalize CRS & Validate Geometry Per Feature Jump to heading
CRS mismatches and invalid geometries are the primary causes of pipeline stalls at this volume. The executor in Step 3 already enforces the rules below per feature, but treat them as an explicit contract:
- Reject files with a missing or malformed
.prj(src.crs is Noneraises and quarantines) - Reproject to
EPSG:4326withfiona.transform.transform_geomonly whenneeds_transformis true — comparingpyproj.CRSobjects avoids reprojecting data already in the target frame - Skip features with empty or absent coordinates rather than writing null geometries
- Constrain output geometry to
required_geometryvia the output schema
Adhere to the OGC Simple Features specification for geometry validity. When a single directory mixes several projections, the per-file equality check is the cheap first pass; for the harder case of reconciling many frames in one output set, defer to the Projection Normalization Workflows strategy rather than special-casing each EPSG code inline. Reference the GDAL Shapefile Driver documentation for the field-name-truncation and attribute-type limits that the output schema must respect.
Verify: spot-check one standardized output and assert it landed in the target frame with the constrained geometry type — python -c "import fiona; src=fiona.open('output_standardized/sample_standardized.shp'); print(src.crs, src.schema['geometry'])" should print EPSG:4326 and Polygon. Any other CRS means a reprojection branch was skipped.
Step 5 — Verify the Flat Memory Profile Jump to heading
Confirm two invariants before promoting a run: RSS stayed bounded, and every rejected file produced a quarantine record. Sample the per-batch RSS log and assert the slope is flat — a steadily climbing baseline across batches is the signature of a leak that the chunk boundary failed to reset.
# Confirm RSS never crossed the 2.0 GB hard ceiling across the full run
grep "Batch " etl.log | awk -F 'RSS ' '{print $2}' | sort -n | tail -1
# Expected: a value well under 2048 MB, and roughly equal to the first batch's RSS
# Requires: Python >= 3.10, pytest >= 7
# A fixture-style assertion you can run after a 10k batch in CI.
from pathlib import Path
def test_no_silent_drops(input_count: int) -> None:
standardized = len(list(Path("output_standardized").glob("*_standardized.shp")))
quarantined = len(list(Path("quarantine").glob("*.shp")))
# Every input file is accounted for — nothing vanished silently.
assert standardized + quarantined == input_count
Watch for these log lines as positive confirmation that teardown and quarantine are firing:
INFO Batch 1 (75 files) — RSS 412 MB
INFO Batch 134 (75 files) — RSS 419 MB # baseline barely moved over 10k files
WARNING High RSS (1521 MB) — forcing second GC pass.
ERROR Quarantined county_2009_legacy.shp: Missing .prj file — CRS undefined
If the per-batch RSS climbs monotonically instead of holding near the first batch’s value, a handle is escaping the with block — confirm no dataset reference is being stored outside the loop.
Troubleshooting Jump to heading
| Symptom | Likely cause | Fix |
|---|---|---|
| RSS climbs steadily across batches and eventually OOM-kills the worker | A fiona dataset reference is held outside the with block, so gc.collect() cannot reclaim it |
Never assign src/dst to an outer-scope variable; let the context manager close every handle inside the loop |
fiona.errors.DriverError: Too many open files |
Open-descriptor budget exceeded — sidecar reads or a low ulimit -n |
Keep GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR, raise ulimit -n to 512+, and confirm batches close before the next opens |
Every file lands in quarantine/ with “Schema mismatch exceeds threshold” |
Source alias lists in schema_mapping.yaml do not cover this dataset’s field names |
Add the directory’s actual field names to source_fields, or raise max_field_mismatch_pct only if partial records are acceptable |
pyproj.exceptions.CRSError while comparing CRS |
A malformed or truncated .prj produces an unparseable WKT |
Quarantine and repair upstream; validate codes first via Step-by-Step EPSG Code Normalization for Mixed Datasets |
Output area_sqm is None for every record |
The transform expression ran on a string value, or the source field was unmatched |
Confirm the source field is in source_fields and numeric; coercion runs after the transform, so a non-numeric input yields None |
ru_maxrss numbers look 1000x too large |
Running on macOS, where ru_maxrss is bytes, not kilobytes |
Divide by 1024 * 1024 on macOS; the /1024 factor in get_rss_mb is correct for Linux only |
Related Jump to heading
- Batch Schema Processing Pipelines — parent stage covering streaming execution, failure routing, and CI integration for the full batch model
- Implementing Exponential Backoff in Schema Mapping Jobs — resilient retries for the remote registry and CRS calls these batches may issue
- Field Renaming & Type Coercion Rules — the deterministic mapping rules this pipeline evaluates per feature
- Projection Normalization Workflows — reconciling many CRS frames into one standardized output set when a directory mixes projections