How to Batch Process Multiple Shapefiles in Python

On this page

Problem statement

Reprojecting forty shapefiles by hand in a desktop GIS takes an afternoon and goes wrong on file thirty-one. The same job in Python is one loop, whether the operation is:

  • reprojecting many shapefiles to one CRS
  • cleaning or standardizing attribute fields
  • adding calculated columns
  • filtering features based on an attribute
  • exporting processed copies to a new folder

The pattern: find the .shp files, run one GeoPandas function on each, and write the results to a separate folder β€” here on fifteen Natural Earth files, two of them damaged in ways the plain loop writes out without an error.

Quick answer

The safest way to batch process shapefiles in Python is:

  1. define an input folder and output folder
  2. list all shapefiles in the input folder
  3. read each file with GeoPandas
  4. pass the GeoDataFrame into a reusable processing function
  5. save the result with a consistent filename
  6. add basic error handling so one bad file does not stop the whole batch
from pathlib import Path
import geopandas as gpd

input_folder = Path("data/input_shapefiles")
output_folder = Path("data/output_shapefiles")
output_folder.mkdir(parents=True, exist_ok=True)

def process_gdf(gdf):
    gdf = gdf.copy()
    gdf["source"] = "batch_run"
    return gdf

for shp_path in sorted(input_folder.glob("*.shp")):
    gdf = gpd.read_file(shp_path)
    result = process_gdf(gdf)
    output_path = output_folder / f"{shp_path.stem}_processed.shp"
    result.to_file(output_path)

print(len(list(output_folder.glob("*_processed.shp"))), "files written")
15 files written

On the fifteen Natural Earth files this writes fifteen outputs without an error β€” including the lakes that have no .prj, now copied on without one, and the rivers file cut off mid-copy, whose 6 missing geometries go straight through. Adding a field needs no CRS and no geometry, so nothing checked either. That is the case for the error handling and validation below.

This pattern works for reprojection, attribute updates, filtering, clipping, and other folder-based GIS tasks.

Batch loop with per-file error handling

Batch loop with error handlingDoes a file read fail?Log to failed_files and continueYesNoDoes processing fail?Catch the exception and keepgoingYesNoSave output; report the failed countspatialworkflow.io
A batch loop that logs failures and keeps going.

Folders, a loop and one processing function

The four moving parts of any batch shapefile job. the four parts you always writefindglob("*.shp")loopfor path in filesprocessyour logicwriteout_dir / stemOnly the third box ever changes between jobs spatialworkflow.io
Once you see the shape, the loop becomes boilerplate you reuse forever.

Set up input and output folders

Use separate folders for source data and processed outputs. This reduces the risk of overwriting your original shapefiles.

from pathlib import Path

input_folder = Path("data/input_shapefiles")
output_folder = Path("data/output_shapefiles")
output_folder.mkdir(parents=True, exist_ok=True)

Writing to a new folder is safer than editing files in place, especially during testing.

Get all shapefiles from the folder

Use pathlib to find only .shp files in the input folder. If you also need subfolders, use rglob("*.shp") instead of glob("*.shp").

shapefiles = sorted(input_folder.glob("*.shp"))      # glob order is whatever the filesystem returns
print(f"Found {len(shapefiles)} shapefiles")
for path in shapefiles:
    print(path.name)
Found 15 shapefiles
lakes.shp
ne_110m_admin_0_boundary_lines_land.shp
ne_110m_admin_0_countries.shp
ne_110m_admin_1_states_provinces.shp
ne_110m_coastline.shp
ne_110m_geography_regions_polys.shp
ne_110m_glaciated_areas.shp
ne_110m_lakes.shp
ne_110m_land.shp
ne_110m_ocean.shp
ne_110m_populated_places.shp
ne_110m_rivers_lake_centerlines.shp
ne_50m_admin_0_countries.shp
ne_50m_populated_places.shp
rivers.shp

This is useful in mixed folders that may also contain .dbf, .shx, .prj, CSV files, or other data.

A shapefile is not just one file. The .shp file must stay with its related sidecar files such as .dbf, .shx, and often .prj.

Read each shapefile with GeoPandas

Loop through the file paths and load each one into a GeoDataFrame.

import geopandas as gpd

for shp_path in shapefiles:
    gdf = gpd.read_file(shp_path)
    print(shp_path.name, len(gdf), "features")
lakes.shp 24 features
ne_110m_admin_0_boundary_lines_land.shp 331 features
ne_110m_admin_0_countries.shp 177 features
ne_110m_admin_1_states_provinces.shp 51 features
ne_110m_coastline.shp 134 features
ne_110m_geography_regions_polys.shp 60 features
ne_110m_glaciated_areas.shp 11 features
ne_110m_lakes.shp 24 features
ne_110m_land.shp 127 features
ne_110m_ocean.shp 2 features
ne_110m_populated_places.shp 243 features
ne_110m_rivers_lake_centerlines.shp 13 features
ne_50m_admin_0_countries.shp 242 features
ne_50m_populated_places.shp 1251 features
rivers.shp 13 features

At this point, gdf is a standard GeoDataFrame, so you can apply any GeoPandas operation you would normally run on a single shapefile.

Apply the same processing logic to each file

The cleanest pattern is to put the repeated GIS logic in a function.

Here is a simple example that adds a new field and reprojects the data to EPSG:4326.

def process_gdf(gdf):
    gdf = gdf.copy()

    if gdf.crs is None:
        raise ValueError("Input shapefile has no CRS defined")

    gdf = gdf.to_crs("EPSG:4326")
    gdf["processed"] = "yes"

    return gdf

This keeps the loop simple and makes it easier to change the processing step later.

For example, if you receive district boundary shapefiles from multiple offices and need them all in WGS84 with one consistent field added, you can use the same function for every file in the folder.

The same structure also works for:

  • selecting records
  • renaming columns
  • calculating areas or lengths
  • clipping to a boundary
  • standardizing schemas

Save processed shapefiles to a new folder

Build output names consistently so you can trace results back to the original source file.

written = []
try:
    for shp_path in shapefiles:
        gdf = gpd.read_file(shp_path)
        result = process_gdf(gdf)

        output_path = output_folder / f"{shp_path.stem}_processed.shp"
        result.to_file(output_path)
        written.append(output_path.name)
except ValueError as e:
    print(f"stopped after {len(written)} of {len(shapefiles)} files, at {shp_path.name}: {e}")
stopped after 0 of 15 files, at lakes.shp: Input shapefile has no CRS defined

The try around the whole loop is only here to show where it stopped: with no handling per file, the first bad one ends the run, and every file after it is never attempted.

Using a suffix like _processed avoids name collisions and makes output files easy to identify.

If you write back to shapefile format, remember that shapefiles have limitations such as short field names and weaker support for modern data types.

Add basic progress messages and error handling

For real folders, add status messages and catch errors per file.

failed_files = []

for shp_path in shapefiles:
    print(f"Processing: {shp_path.name}")

    try:
        gdf = gpd.read_file(shp_path)
        result = process_gdf(gdf)

        output_path = output_folder / f"{shp_path.stem}_processed.shp"
        result.to_file(output_path)

        print(f"Saved: {output_path.name}")

    except Exception as e:
        print(f"Failed: {shp_path.name} -> {e}")
        failed_files.append((shp_path.name, str(e)))

print("\nBatch complete")

if failed_files:
    print("Failed files:")
    for name, error in failed_files:
        print(f"- {name}: {error}")
Processing: lakes.shp
Failed: lakes.shp -> Input shapefile has no CRS defined
Processing: ne_110m_admin_0_boundary_lines_land.shp
Saved: ne_110m_admin_0_boundary_lines_land_processed.shp
Processing: ne_110m_admin_0_countries.shp
Saved: ne_110m_admin_0_countries_processed.shp
Processing: ne_110m_admin_1_states_provinces.shp
Saved: ne_110m_admin_1_states_provinces_processed.shp
Processing: ne_110m_coastline.shp
Saved: ne_110m_coastline_processed.shp
Processing: ne_110m_geography_regions_polys.shp
Saved: ne_110m_geography_regions_polys_processed.shp
Processing: ne_110m_glaciated_areas.shp
Saved: ne_110m_glaciated_areas_processed.shp
Processing: ne_110m_lakes.shp
Saved: ne_110m_lakes_processed.shp
Processing: ne_110m_land.shp
Saved: ne_110m_land_processed.shp
Processing: ne_110m_ocean.shp
Saved: ne_110m_ocean_processed.shp
Processing: ne_110m_populated_places.shp
Saved: ne_110m_populated_places_processed.shp
Processing: ne_110m_rivers_lake_centerlines.shp
Saved: ne_110m_rivers_lake_centerlines_processed.shp
Processing: ne_50m_admin_0_countries.shp
Saved: ne_50m_admin_0_countries_processed.shp
Processing: ne_50m_populated_places.shp
Saved: ne_50m_populated_places_processed.shp
Processing: rivers.shp
Saved: rivers_processed.shp

Batch complete
Failed files:
- lakes.shp: Input shapefile has no CRS defined

This is a reasonable minimum for a simple batch workflow, though larger jobs usually benefit from logging and validation checks.

Reprojecting and tagging a folder of shapefiles

The data used on this page

A real folder of shapefiles: thirteen layers of Natural Earth (public domain) at 1:110 million and 1:50 million, downloaded into data/raw/ and then copied flat into data/input_shapefiles/. Two more are copies damaged on purpose in the two ways files most often arrive damaged β€” lakes.shp without its .prj, and rivers.shp cut off halfway through, as an interrupted copy leaves it. The target CRS is Equal Earth (EPSG:8857).

import io
import shutil
import zipfile
from pathlib import Path

import requests

NE = {"cultural": ["ne_110m_admin_0_countries", "ne_110m_admin_1_states_provinces", "ne_110m_populated_places",
                   "ne_110m_admin_0_boundary_lines_land", "ne_50m_admin_0_countries", "ne_50m_populated_places"],
      "physical": ["ne_110m_coastline", "ne_110m_land", "ne_110m_ocean", "ne_110m_rivers_lake_centerlines",
                   "ne_110m_lakes", "ne_110m_glaciated_areas", "ne_110m_geography_regions_polys"]}
SRC, DST = Path("data/raw"), Path("data/out")
shutil.rmtree(DST, ignore_errors=True)                 # every run of the page starts from nothing
for theme, names in NE.items():
    for name in names:
        if not (SRC / theme / f"{name}.shp").exists():
            scale = name.split("_")[1]
            reply = requests.get(f"https://naciscdn.org/naturalearth/{scale}/{theme}/{name}.zip", timeout=120)
            reply.raise_for_status()
            zipfile.ZipFile(io.BytesIO(reply.content)).extractall(SRC / theme)

# two deliveries damaged on purpose: a missing .prj, and a .shp cut off mid-copy
bad = SRC / "delivered"
bad.mkdir(exist_ok=True)
for ext in ("shp", "shx", "dbf"):
    shutil.copy(SRC / "physical" / f"ne_110m_lakes.{ext}", bad / f"lakes.{ext}")
for ext in ("shp", "shx", "dbf", "prj"):
    shutil.copy(SRC / "physical" / f"ne_110m_rivers_lake_centerlines.{ext}", bad / f"rivers.{ext}")
data = (bad / "rivers.shp").read_bytes()
(bad / "rivers.shp").write_bytes(data[: len(data) // 2])

# the same fifteen, flat in one folder, as most batch scripts expect them
flat = Path("data/input_shapefiles")
shutil.rmtree(flat, ignore_errors=True)
flat.mkdir(parents=True)
for f in SRC.rglob("*"):
    if f.is_file() and f.suffix in (".shp", ".shx", ".dbf", ".prj", ".cpg"):
        shutil.copy(f, flat / f.name)
print(len(sorted(flat.glob("*.shp"))), "shapefiles in", flat)
15 shapefiles in data/input_shapefiles
What three versions of the batch loop did with two damaged shapefiles and with Antarctica in Web Mercator. three loops, fifteen real fileslooplakes, no .prjrivers, cut offAntarctica β†’ 3857quick answerwritten, no CRSwritten, 6 emptyβ€”no per-file handlingrun stops: 0 of 15never reachednever reachedExample 4failed by namefailed by nameflagged invalidNatural Earth at 1:110m and 1:50m, plus two damaged copies spatialworkflow.io
Only the version that validates says anything about the two damaged files, and only it notices what Web Mercator did to Antarctica.

Example 1: Batch read and write shapefiles from one folder

This example shows the basic read-and-write loop with no extra GIS transformation.

from pathlib import Path
import geopandas as gpd

input_folder = Path("data/input_shapefiles")
output_folder = Path("data/written_shapefiles")
output_folder.mkdir(parents=True, exist_ok=True)

for shp_path in input_folder.glob("*.shp"):
    gdf = gpd.read_file(shp_path)
    output_path = output_folder / shp_path.name
    gdf.to_file(output_path)

Example 2: Batch reproject shapefiles to a target CRS

A realistic task is converting many files to one CRS before later analysis.

from pathlib import Path
import geopandas as gpd

input_folder = Path("data/input_shapefiles")
output_folder = Path("data/reprojected_shapefiles")
output_folder.mkdir(parents=True, exist_ok=True)

target_crs = "EPSG:8857"          # Equal Earth; Natural Earth is already in EPSG:4326

for shp_path in sorted(input_folder.glob("*.shp")):
    gdf = gpd.read_file(shp_path)

    if gdf.crs is None:
        print(f"Skipping {shp_path.name}: missing CRS")
        continue

    gdf = gdf.to_crs(target_crs)
    output_path = output_folder / f"{shp_path.stem}_equal_earth.shp"
    gdf.to_file(output_path)
Skipping lakes.shp: missing CRS

Example 3: Batch add or update an attribute field

This is useful for standardizing datasets before merging or reporting.

from pathlib import Path
import geopandas as gpd

input_folder = Path("data/input_shapefiles")
output_folder = Path("data/standardized_shapefiles")
output_folder.mkdir(parents=True, exist_ok=True)

for shp_path in input_folder.glob("*.shp"):
    gdf = gpd.read_file(shp_path)

    gdf["src_file"] = shp_path.stem

    if "status" in gdf.columns:
        gdf["status"] = gdf["status"].astype(str).str.upper()

    output_path = output_folder / f"{shp_path.stem}_updated.shp"
    gdf.to_file(output_path)

Example 4: Batch process with try/except and progress reporting

This version is safer for larger folders.

from pathlib import Path
import geopandas as gpd

input_folder = Path("data/input_shapefiles")
output_folder = Path("data/output_shapefiles")
output_folder.mkdir(parents=True, exist_ok=True)

failed = []

def process_gdf(gdf, file_name):
    gdf = gdf.copy()
    if gdf.crs is None:
        raise ValueError("Missing CRS")
    if gdf.geometry.isna().any():                        # a truncated .shp reads without error
        raise ValueError(f"{gdf.geometry.isna().sum()} of {len(gdf)} geometries missing")
    gdf = gdf.to_crs("EPSG:3857")
    gdf["src_name"] = file_name
    return gdf

for shp_path in sorted(input_folder.glob("*.shp")):
    try:
        gdf = gpd.read_file(shp_path)
        result = process_gdf(gdf, shp_path.stem)
        output_path = output_folder / f"{shp_path.stem}_processed.shp"
        result.to_file(output_path)
        invalid = (~result.is_valid).sum()
        if invalid:
            print(f"Saved {output_path.name}, but {invalid} geometries are invalid; "
                  f"y reaches {result.total_bounds[1]:,.0f} m")
    except Exception as e:
        failed.append((shp_path.name, str(e)))
        print(f"Error in {shp_path.name}: {e}")

print(f"\nDone. Failed files: {len(failed)}")
Error in lakes.shp: Missing CRS
Saved ne_110m_admin_0_countries_processed.shp, but 1 geometries are invalid; y reaches -242,528,681 m
Saved ne_110m_land_processed.shp, but 1 geometries are invalid; y reaches -242,528,681 m
Error in rivers.shp: 6 of 13 geometries missing

Done. Failed files: 2

Validation is what separates this version from the loop in the quick answer: the lakes and the truncated rivers now fail by name instead of passing through. It also shows something a clean run would hide. Web Mercator cannot show the poles, so Antarctica's coastline is projected to a y of about βˆ’242,500 km, and the land and country polygons that touch it come out invalid. The files are written, and nothing raised; only the check said so. For world data, use a projection that has the poles in it.

Why a batch job is a loop plus one function

This approach works because most GIS batch jobs are just file loops plus one reusable transformation.

Why globbing *.shp is correct even though a shapefile is several files. glob the .shp, get the rest for freewhat it matchesresult*.shpone entry per datasetβœ“correct*.dbfone entry per datasetβœ•unreadable path*.prjmisses datasets with no .prjβœ•incomplete*4+ entries per datasetβœ•processes each 4Γ—GeoPandas reads the sidecars automatically once you hand it the .shp spatialworkflow.io
Globbing anything else either misses datasets or processes each one repeatedly.

The core pieces are:

  • a folder of input shapefiles
  • a loop over file paths
  • a processing function that handles one GeoDataFrame
  • an output folder for clean results

That makes the workflow easy to test. First, make sure the function works on one shapefile. Then run it across the whole folder.

GeoPandas is usually enough when you need to process a moderate number of vector files with standard operations. For larger pipelines, you may also want:

  • structured logging instead of print
  • validation checks before saving
  • GeoPackage output instead of shapefile
  • batch processing across subfolders with rglob("*.shp")

If you plan to merge outputs later, consistent CRS and field names matter more than the loop itself.

Missing CRSs, mixed types and accidental overwrites

Some files are missing CRS information

If a file has no CRS, reprojection with to_crs() is not reliable and may fail. You need to know the correct original CRS and assign it first with set_crs().

Mixed geometry types across shapefiles

A batch folder may contain points, lines, and polygons. Some workflows work for all geometry types, but others do not. For example, polygon area calculations do not make sense for point layers.

Field name length limits in shapefiles

Shapefiles have a 10-character field name limit. Longer column names may be truncated when you save output.

Invalid geometries

Spatial operations can fail on broken geometries. If clipping, overlay, or buffering fails, check whether some features are invalid and repair them before running the full batch.

Overwriting outputs accidentally

Avoid writing processed files back into the same folder with the same names. Use a separate destination folder and add a suffix such as _processed.

If you need the basics first, see How to Read a Shapefile in Python with GeoPandas.

Related tasks:

If some files fail to load, see How to Read a Shapefile in Python with GeoPandas first to verify that each input file can be opened correctly.

FAQ

How do I loop through all shapefiles in a folder with Python?

Use pathlib and glob:

from pathlib import Path

for shp_path in Path("data/input").glob("*.shp"):
    print(shp_path.name)

This is the standard way to loop through shapefiles in Python for folder-based GIS automation.

Can GeoPandas batch process shapefiles in subfolders too?

Yes. Use rglob("*.shp") instead of glob("*.shp").

from pathlib import Path

for shp_path in Path("data/input").rglob("*.shp"):
    print(shp_path)

This is useful when your data is organized by region, date, or project folder.

Should I overwrite the original shapefiles or save new copies?

Save new copies unless you have a very controlled workflow. A separate output folder is safer and makes it easier to review results.

Is shapefile the best output format for batch GIS workflows?

Not always. Shapefile is still common, but it has field name limits and other restrictions. For many modern workflows, GeoPackage is a better output format, especially when schema consistency matters.

How do I merge all processed shapefiles into one file after the batch?

Collect each GeoDataFrame into a list and combine them with pd.concat, then wrap the result back into a GeoDataFrame:

import pandas as pd, geopandas as gpd
gdfs = [gpd.read_file(p) for p in sorted(output_folder.glob("*_processed.shp"))]
print({str(g.crs) for g in gdfs})
gdfs = [g for g in gdfs if g.crs == "EPSG:3857"]
combined = gpd.GeoDataFrame(pd.concat(gdfs, ignore_index=True), crs=gdfs[0].crs)
print(len(gdfs), "files,", len(combined), "rows,", combined.geom_type.nunique(), "geometry types")
{'EPSG:4326', 'None', 'EPSG:3857'}
13 files, 2666 rows, 5 geometry types

Make sure every input shares the same CRS before concatenating, otherwise coordinates will not line up β€” pd.concat refuses outright. On this page the check is not academic: the lakes and rivers failed in Example 4, so the outputs the quick answer wrote under the same names were never replaced, and the folder holds files in three CRSs.

How can I speed up reading many shapefiles in a batch?

The read itself is rarely the bottleneck; writing shapefiles and running per-feature geometry operations usually dominate. If you have many independent files, process them in parallel with concurrent.futures.ProcessPoolExecutor, and write to GeoPackage instead of shapefile to avoid sidecar-file overhead.

Can I batch process GeoJSON or GeoPackage files instead of shapefiles?

Yes. The same loop works for any format gpd.read_file() supports; just change the glob pattern, for example input_folder.glob("*.geojson") or input_folder.glob("*.gpkg"). GeoPackage avoids the shapefile field-name and type limitations on output.

How do I skip files that are already processed when re-running the batch?

Check whether the output path exists before processing and continue if it does:

for shp_path in sorted(input_folder.glob("*.shp")):
    output_path = output_folder / f"{shp_path.stem}_processed.gpkg"
    if output_path.exists():
        continue
    ...

This makes the batch resumable after a failure without redoing completed work.