How to Standardise and Repair CRS Across a Folder of Files in Python
On this page
Problem statement
A folder of vector data from several people and portals rarely agrees on its coordinate reference system. Some files are in WGS 84, some in a national grid or Web Mercator, one has lost its .prj, and one claims a CRS its coordinates do not have. Fixing one file is a two-line job; fixing a folder is a batch job, and the risk moves from any single fix to the loop that applies them. A loop that relabels and reprojects without looking will turn a mislabelled file into confident nonsense, read a truncated file without an error, and leave no record of which files it changed.
This page repairs a folder built from real Rutland layers β OpenStreetMap roads, Land Registry parcels, ONS wards β damaged in the ways such folders are: one script that inventories every file, decides per file whether to keep, label, relabel, reproject or skip, writes clean GeoPackages, and reports what it did.
Quick answer
Loop over the folder with pathlib, read each file, fill in a missing CRS with set_crs() only when you know the true source CRS, reproject everything to one common target with to_crs(), and write each result to GeoPackage. Wrap every file in its own try/except so one corrupt file does not abort the batch, and collect a row of metadata per file for a summary table at the end.
import geopandas as gpd
from pathlib import Path
src_dir = Path("data/raw")
out_dir = Path("data/clean")
out_dir.mkdir(parents=True, exist_ok=True)
TARGET_CRS = "EPSG:4326" # common output CRS for the whole folder
ASSUME_CRS = "EPSG:27700" # only used when a file has NO crs at all
PATTERNS = ("*.shp", "*.geojson", "*.gpkg")
summary = []
for path in sorted(p for pat in PATTERNS for p in src_dir.glob(pat)):
try:
gdf = gpd.read_file(path)
original = str(gdf.crs)
if gdf.crs is None:
gdf = gdf.set_crs(ASSUME_CRS) # label only, no coordinates move
action = f"set_crs -> {ASSUME_CRS}"
else:
action = "kept"
if gdf.crs.to_epsg() != 4326:
gdf = gdf.to_crs(TARGET_CRS) # reproject, coordinates move
action += f", to_crs -> {TARGET_CRS}"
xmin, ymin, xmax, ymax = gdf.total_bounds
if not (-180 <= xmin <= xmax <= 180 and -90 <= ymin <= ymax <= 90):
raise ValueError(f"output bounds {gdf.total_bounds.round(0)} are not degrees: the source label is wrong")
empty = int(gdf.geometry.isna().sum())
if empty:
action += f", {empty} rows with no geometry"
out_path = out_dir / f"{path.stem}.gpkg"
gdf.to_file(out_path, driver="GPKG")
summary.append((path.name, original, str(gdf.crs), len(gdf), action))
except Exception as exc:
summary.append((path.name, "ERROR", "-", 0, str(exc)[:70]))
for name, before, after, n, action in summary:
print(f"{name:24} {before:11} -> {after:10} {n:5} rows | {action}")
broken.shp EPSG:4326 -> EPSG:4326 500 rows | kept, 499 rows with no geometry
grid_mislabelled.shp ERROR -> - 0 rows | output bounds [486125. 293357. 497122. 300000.] are not degrees: the s
parcels_no_prj.shp None -> EPSG:4326 1000 rows | set_crs -> EPSG:27700, to_crs -> EPSG:4326
roads_bng.gpkg EPSG:27700 -> EPSG:4326 500 rows | kept, to_crs -> EPSG:4326
roads_osm.shp EPSG:4326 -> EPSG:4326 500 rows | kept
suspect.geojson ERROR -> - 0 rows | output bounds [479688. 292648. 506296. 318928.] are not degrees: the s
wards_webmercator.shp EPSG:3857 -> EPSG:4326 15 rows | kept, to_crs -> EPSG:4326
Two lines of the report are the ones that earn it. grid_mislabelled.shp and suspect.geojson claim WGS 84 and hold British National Grid metres; without the bounds check they would have been copied through as "kept", mislabelled for good. And broken.shp β a shapefile cut short β read without an error: 500 rows, 499 of them with no geometry, which only the null count reveals. The rest of this article explains each decision so you can adapt it safely instead of running it blind.
Inspect, label and reproject every file in a folder
Discover the files
Use pathlib.Path plus glob to find every vector file. Globbing one pattern at a time and chaining the results keeps things readable and lets you control exactly which extensions you accept. Use rglob instead of glob if your data is nested in subfolders.
from pathlib import Path
src_dir = Path("data/raw")
PATTERNS = ("*.shp", "*.geojson", "*.json", "*.gpkg")
files = sorted(p for pat in PATTERNS for p in src_dir.glob(pat))
for f in files:
print(f.name)
broken.shp
grid_mislabelled.shp
parcels_no_prj.shp
roads_bng.gpkg
roads_osm.shp
suspect.geojson
wards_webmercator.shp
A couple of notes: globbing *.shp deliberately ignores the .dbf, .shx, and .prj sidecars β GeoPandas reads them automatically from the .shp. And a single .gpkg can hold multiple layers, which we will handle in the edge cases.
Inspect each file's CRS
Before changing anything, list what each file claims and where its coordinates actually are:
import geopandas as gpd
from pathlib import Path
for path in sorted(Path("data/raw").glob("*.shp")):
gdf = gpd.read_file(path)
if gdf.crs is None:
print(f"{path.name}: NO CRS bounds={gdf.total_bounds}")
else:
print(f"{path.name}: {gdf.crs.to_epsg()} bounds={gdf.total_bounds}")
broken.shp: 4326 bounds=[-0.7246057 52.5961867 -0.724183 52.5964195]
grid_mislabelled.shp: 4326 bounds=[486124.63 293356.863 497122.39 299999.58 ]
parcels_no_prj.shp: NO CRS bounds=[486124.63 293356.863 497122.39 299999.58 ]
roads_osm.shp: 4326 bounds=[-0.8324235 52.5321281 -0.498822 52.7345558]
wards_webmercator.shp: 3857 bounds=[ -91478.07999173 6895563.38722839 -47686.55522131 6938694.32658967]
Three of the five shapefiles have a problem that reprojection alone will not fix. parcels_no_prj.shp has no CRS. grid_mislabelled.shp claims EPSG:4326 with bounds in the hundreds of thousands, which no longitude can be: its label is wrong, not missing. And broken.shp claims a sensible CRS with bounds about 30 metres across for a layer of roads, which is the first sign it was cut short.
Set a missing CRS (only if you know the source)
A file with no CRS gets one only from evidence about where it came from β here the supplier, for the parcels:
import geopandas as gpd
gdf = gpd.read_file("data/raw/parcels_no_prj.shp")
if gdf.crs is None:
# We confirmed from the supplier that this is British National Grid.
gdf = gdf.set_crs("EPSG:27700")
print(gdf.crs) # EPSG:27700
print(gdf.total_bounds) # eastings/northings in metres, unchanged
EPSG:27700
[486124.63 293356.863 497122.39 299999.58 ]
In a batch, the source of each label belongs in the code or a lookup table beside it, not in somebody's memory. A file nobody can vouch for is skipped and reported, not guessed (how to find a source CRS, and why guessing is dangerous: cannot transform naive geometries).
Reproject to a common target CRS
Only once every file's label is right does converting them to one target make them agree:
import geopandas as gpd
TARGET_CRS = "EPSG:4326"
gdf = gpd.read_file("data/raw/roads_bng.gpkg") # EPSG:27700
if gdf.crs is None:
raise ValueError("Set a source CRS before reprojecting")
if gdf.crs.to_epsg() != 4326:
gdf = gdf.to_crs(TARGET_CRS)
print(gdf.crs) # EPSG:4326
print(gdf.total_bounds) # now in degrees
EPSG:4326
[-0.83242349 52.5321281 -0.49882199 52.7345558 ]
The guard matters more in a loop than in a script: without it, the first unlabelled file stops the batch (Example 4 shows exactly that).
Write out and handle errors per file
Write each cleaned layer to GeoPackage with driver="GPKG". Wrap the read-fix-write cycle for each file in try/except so that a corrupt geometry, a missing sidecar, or an unreadable file produces a logged error rather than a stack trace that stops the whole batch.
import geopandas as gpd
from pathlib import Path
out_dir = Path("data/clean")
out_dir.mkdir(parents=True, exist_ok=True)
for path in sorted(Path("data/raw").glob("*.shp")):
try:
gdf = gpd.read_file(path)
if gdf.crs is None:
gdf = gdf.set_crs("EPSG:27700")
gdf = gdf.to_crs("EPSG:4326")
gdf.to_file(out_dir / f"{path.stem}.gpkg", driver="GPKG")
print(f"OK {path.name}")
except Exception as exc:
print(f"FAIL {path.name}: {exc}")
OK broken.shp
OK grid_mislabelled.shp
OK parcels_no_prj.shp
OK roads_osm.shp
OK wards_webmercator.shp
Produce a summary report
Collect one record per file as you go, then print a table β or write it to CSV β showing the original CRS, the final CRS, the row count, and the action taken. This is your audit trail; keep it.
import geopandas as gpd
import pandas as pd
from pathlib import Path
rows = []
for path in sorted(Path("data/raw").glob("*.shp")):
try:
gdf = gpd.read_file(path)
before = str(gdf.crs)
if gdf.crs is None:
gdf = gdf.set_crs("EPSG:27700")
gdf = gdf.to_crs("EPSG:4326")
rows.append({"file": path.name, "crs_before": before, "crs_after": str(gdf.crs), "rows": len(gdf),
"no_geometry": int(gdf.geometry.isna().sum()),
"x_range": f"{gdf.total_bounds[0]:.1f}..{gdf.total_bounds[2]:.1f}", "status": "ok"})
except Exception as exc:
rows.append({"file": path.name, "crs_before": "-",
"crs_after": "-", "rows": 0, "status": f"error: {exc}"})
report = pd.DataFrame(rows)
print(report.to_string(index=False))
report.to_csv("data/clean/_crs_report.csv", index=False)
file crs_before crs_after rows no_geometry x_range status
broken.shp EPSG:4326 EPSG:4326 500 499 -0.7..-0.7 ok
grid_mislabelled.shp EPSG:4326 EPSG:4326 1000 0 486124.6..497122.4 ok
parcels_no_prj.shp None EPSG:4326 1000 0 -0.7..-0.6 ok
roads_osm.shp EPSG:4326 EPSG:4326 500 0 -0.8..-0.5 ok
wards_webmercator.shp EPSG:3857 EPSG:4326 15 0 -0.8..-0.4 ok
This simpler loop marks every file ok. The two extra columns are what make the report worth keeping: x_range shows grid_mislabelled.shp still in the hundreds of thousands after a "reprojection" to degrees, and no_geometry shows the truncated broken.shp for what it is.
Repairing a deliberately broken folder of Rutland layers
The data used on this page
data/raw is assembled from real layers β Rutland's roads from Geofabrik's OpenStreetMap extract of 1 January 2026 (Β© OpenStreetMap contributors, ODbL), HM Land Registry's INSPIRE parcels (Β© Crown copyright and database rights; OGL) and the ONS December 2024 wards (OGL) β and then broken on purpose: a shapefile without its .prj, British National Grid coordinates labelled as WGS 84 in a shapefile and in a GeoJSON, a Web Mercator file, a truncated shapefile, and one file in a subfolder.
import shutil
from pathlib import Path
import geopandas as gpd
import requests
Path("data").mkdir(exist_ok=True)
for target, url in (("data/rutland.shp.zip", "https://download.geofabrik.de/europe/united-kingdom/england/rutland-260101-free.shp.zip"),
("data/Rutland.zip", "https://use-land-property-data.service.gov.uk/datasets/inspire/download/Rutland.zip")):
if not Path(target).exists():
Path(target).write_bytes(requests.get(url, timeout=300).content)
if not Path("data/wards.geojson").exists():
Path("data/wards.geojson").write_bytes(requests.get(
"https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/"
"Wards_December_2024_Boundaries_UK_BFC/FeatureServer/0/query",
params={"where": "LAD24CD='E06000017'", "outFields": "WD24NM", "outSR": 27700, "f": "geojson"}, timeout=120).content)
shutil.rmtree("data/raw", ignore_errors=True)
shutil.rmtree("data/clean", ignore_errors=True)
Path("data/raw/north").mkdir(parents=True)
roads = gpd.read_file("/vsizip/data/rutland.shp.zip/gis_osm_roads_free_1.shp", rows=500, columns=["osm_id", "fclass"])
parcels = gpd.read_file("/vsizip/data/Rutland.zip/Land_Registry_Cadastral_Parcels.gml", rows=1000, columns=["INSPIREID"], DOWNLOAD_SCHEMA="NO")
wards = gpd.read_file("data/wards.geojson")
roads.to_file("data/raw/roads_osm.shp") # EPSG:4326, correct
parcels.to_file("data/raw/parcels_no_prj.shp"); Path("data/raw/parcels_no_prj.prj").unlink() # the .prj went missing
roads.to_crs(27700).to_file("data/raw/roads_bng.gpkg") # EPSG:27700, correct
wards.to_crs(3857).to_file("data/raw/wards_webmercator.shp") # EPSG:3857, correct
wards.set_crs(4326, allow_override=True).to_file("data/raw/suspect.geojson") # metres labelled as degrees
parcels.set_crs(4326, allow_override=True).to_file("data/raw/grid_mislabelled.shp")
wards.to_file("data/raw/north/wards.shp") # nested
roads.to_file("data/raw/broken.shp")
Path("data/raw/broken.shp").write_bytes(Path("data/raw/broken.shp").read_bytes()[:300]) # truncated
print(sorted(str(p.relative_to("data/raw")) for p in Path("data/raw").rglob("*") if p.suffix in (".shp", ".gpkg", ".geojson")))
['broken.shp', 'grid_mislabelled.shp', 'north/wards.shp', 'parcels_no_prj.shp', 'roads_bng.gpkg', 'roads_osm.shp', 'suspect.geojson', 'wards_webmercator.shp']
Example 1: Count files by CRS before you touch anything
import geopandas as gpd
from collections import Counter
from pathlib import Path
counts = Counter()
for path in sorted(Path("data/raw").glob("*.shp")):
gdf = gpd.read_file(path, rows=1) # read 1 row, cheap CRS probe
counts[str(gdf.crs)] += 1
for crs, n in counts.most_common():
print(f"{n:3} files {crs}")
3 files EPSG:4326
1 files None
1 files EPSG:3857
Reading a single row with rows=1 is enough to inspect the CRS and is far faster than loading full geometries when you only want a census of the folder.
Example 2: Detect a mislabelled CRS via bounds
import geopandas as gpd
gdf = gpd.read_file("data/raw/suspect.geojson")
xmin, ymin, xmax, ymax = gdf.total_bounds
looks_like_degrees = (-180 <= xmin <= 180) and (-90 <= ymin <= 90)
if gdf.crs is not None and gdf.crs.to_epsg() == 4326 and not looks_like_degrees:
print("Labelled EPSG:4326 but coordinates are not degrees -> wrong label")
Labelled EPSG:4326 but coordinates are not degrees -> wrong label
Example 3: Relabel a wrong CRS without reprojecting
When a file carries the wrong label (e.g. it is really in metres but tagged EPSG:4326), use set_crs(..., allow_override=True) to correct the label, then reproject.
import geopandas as gpd
gdf = gpd.read_file("data/raw/grid_mislabelled.shp")
# It claims 4326 but the values are British National Grid metres.
gdf = gdf.set_crs("EPSG:27700", allow_override=True) # fix the label
gdf = gdf.to_crs("EPSG:4326") # now reproject correctly
Without allow_override=True, GeoPandas refuses to replace an existing CRS and raises an error β a deliberate guard against accidental mislabelling.
Example 4: Recurse into subfolders and mirror the tree
import geopandas as gpd
from pathlib import Path
src_dir = Path("data/raw")
out_dir = Path("data/clean")
for path in sorted(src_dir.rglob("*.shp")):
rel = path.relative_to(src_dir).with_suffix(".gpkg")
gdf = gpd.read_file(path)
if gdf.crs is None:
print(f"skipped {rel.with_suffix('.shp')}: no CRS") # to_crs would raise here
continue
dest = out_dir / rel
dest.parent.mkdir(parents=True, exist_ok=True)
gdf.to_crs("EPSG:4326").to_file(dest, driver="GPKG")
print(sorted(str(p.relative_to(out_dir)) for p in out_dir.rglob("*.gpkg")))
skipped parcels_no_prj.shp: no CRS
['broken.gpkg', 'grid_mislabelled.gpkg', 'north/wards.gpkg', 'parcels_no_prj.gpkg', 'roads_bng.gpkg', 'roads_osm.gpkg', 'wards_webmercator.gpkg']
The first version of this example had no guard, and stopped at parcels_no_prj.shp with Cannot transform naive geometries β the whole batch lost to one file.
Example 5: Combine everything into one GeoPackage with many layers
import geopandas as gpd
from pathlib import Path
out_path = Path("data/clean/standardised.gpkg")
out_path.parent.mkdir(parents=True, exist_ok=True)
for path in sorted(Path("data/raw").glob("*.shp")):
gdf = gpd.read_file(path)
if gdf.crs is None:
gdf = gdf.set_crs("EPSG:27700")
gdf = gdf.to_crs("EPSG:4326")
gdf.to_file(out_path, layer=path.stem, driver="GPKG") # one layer per file
A single GeoPackage with one layer per file keeps a project tidy and avoids the multi-file sprawl of shapefiles.
Why a folder needs a report, not just a loop
Each file gets one of five outcomes: kept as it is, labelled because its CRS was missing, relabelled because its CRS was wrong, reprojected to the target, or skipped. Every one of them is easy on its own. The danger in a folder is that a loop applies them without anyone looking, and two of the failures on this page raise no error at all. The files that claimed WGS 84 while holding British National Grid metres would have passed straight through as "kept" β the simple report's x_range column shows grid_mislabelled.shp still in the hundreds of thousands after a "conversion" to degrees. And broken.shp read 500 rows, 499 of them without geometry. A report with bounds and null counts is the only thing that sees either.
The report is also the record. A month later, "which files did we relabel, and on whose word?" has an answer only if the run wrote it down. Which target to standardise to is a separate decision, made once for the project: choosing a projected CRS for your study area.
Bounds, correct-but-different CRSs and big folders
Check the bounds after every change, not only before
A wrong label survives to_crs(): the output is labelled correctly and positioned wrongly. Bounds checked after the conversion against the target's expected range catch it before it reaches the output folder.
Files genuinely in different correct CRSs
A folder can legitimately contain files in different CRSs that are all correctly labelled β for example data from several countries. That is fine: do not set_crs() anything (the labels are right), and let to_crs(TARGET_CRS) reconcile them. Your script should only relabel files where gdf.crs is None or where you have positively confirmed a wrong label.
Large folders and memory
gpd.read_file() loads the entire file into memory. For very large files or huge folders, process one file at a time inside the loop (as shown) so only one GeoDataFrame is resident at once, and let it fall out of scope before the next iteration. If a single file is too big to fit, use pyogrio with the rows/bbox read options or process it in chunks rather than relabelling the whole thing at once.
Skipped files belong in the report
An unknown source CRS, an unreadable file or a file with no geometry gets a row with its reason, not a silent continue. A file missing from the output with no explanation looks, a month later, exactly like a file that was cleaned.
Internal links
- How to Reproject Spatial Data in Python (GeoPandas)
- How to Fix CRS Mismatch in GeoPandas
- Cannot Transform Naive Geometries to CRS: How to Fix It in GeoPandas
- EPSG Codes Explained: How to Choose the Right CRS in Python
- How to Batch Process Multiple Shapefiles in Python
- The Python GIS Data Cleaning Checklist
FAQ
How do I know what the source CRS of a file with no .crs should be?
Check the data supplier's documentation or metadata first β it is usually stated. Failing that, look at the magnitude of total_bounds: values within -180..180 / -90..90 suggest a geographic CRS like EPSG:4326, while large six- or seven-digit numbers suggest a projected national grid or UTM zone. If you still cannot tell, do not guess; mark the file as skipped.
Why does set_crs() raise an error on a file that already has a CRS?
By default set_crs() refuses to overwrite an existing CRS to prevent accidental mislabelling. If you genuinely need to correct a wrong label, pass allow_override=True. Only do this when you are certain the existing label is wrong and you know the correct one.
Can one GeoPackage hold all my cleaned files?
Yes. Write each file to the same .gpkg with a distinct layer= name, as in Example 5. This keeps a project to a single file instead of scattering dozens of shapefiles, and every layer stores its own CRS internally.
Is reading with rows=1 safe for checking the CRS?
Yes. The CRS is file-level metadata, so reading a single feature is enough to inspect it and is much faster than loading every geometry. Use it for cheap census passes over a large folder, then do the full read only when you are ready to repair and write.