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.

RSS vs Files Processed — Monolithic Load vs Chunked Pipeline A line chart with files processed on the x-axis (0 to 10,000) and resident set size on the y-axis (0 to 2 GB). The monolithic-load line rises steadily and crosses the 2 GB hard ceiling near 6,000 files, where it is OOM-killed. The chunked-pipeline line stays low as a flat sawtooth, climbing slightly within each 75-file batch then dropping back to a ~410 MB baseline after gc.collect, never approaching the ceiling across the full 10,000-file run. RSS files 2.0 GB 1.5 GB 0 0 5k 10k 2.0 GB hard ceiling — OOM kill 1.5 GB soft GC trigger monolithic load — OOM RSS climbs every read chunked pipeline — flat baseline sawtooth resets to ~410 MB after gc.collect() every 75 files

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:

python
# 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
yaml
# 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.

python
# 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.

Per-File Lifecycle — Every Path Converges on Explicit Teardown A flow diagram for one shapefile inside the batch loop. The happy path runs top to bottom: open the source handle, validate schema match and that a CRS is present, build the output schema, then stream features (skipping empty geometries, reprojecting only when needed) into the standardized output. A schema mismatch, a missing .prj, or any other exception branches off to a quarantine box that copies the raw file and removes the original. Both the success path and the quarantine path flow into a single finally block that closes the fiona handle, calls gc.collect to reclaim cyclic references, and checks RSS against the 1.5 GB soft trigger, forcing a second collection if exceeded. The diagram emphasizes that no dataset handle outlives the loop iteration. 1. fiona.open(src, "r") acquire C-level handle 2. schema match? src.crs present? 3. write standardized features skip empty geom · reproject if needed quarantine: copy raw + unlink log ERROR, never crash batch finally: explicit teardown with-block closes handle gc.collect() RSS > 1.5 GB → second pass output_standardized/…_standardized.shp log INFO Completed pass fault / exception always no handle outlives the loop iteration
python
# 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:

python
# 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 None raises and quarantines)
  • Reproject to EPSG:4326 with fiona.transform.transform_geom only when needs_transform is true — comparing pyproj.CRS objects 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_geometry via 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.

bash
# 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
python
# 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:

text
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