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.

Seven files in four coordinate systems, run through the batch to EPSG:4326. the folder, run once7 files4326 Β· 27700 Β· 3857Β· nonenone: set_crsparcels_no_prj β†’27700to_crs(4326)coordinates movebounds check2 files refused5 writtenbroken.shp: 499emptyTwo files claimed 4326 and held metres; only the bounds check caught them spatialworkflow.io
The batch below on this page's folder: the two files that claimed WGS 84 and held metres were refused by the bounds check.
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

A CRS inventory of a folder, taken before anything is changed. inventory first, repair secondfilecrsactionroads_osm.shpEPSG:4326already targetroads_bng.gpkgEPSG:27700reprojectwards_webmercator.shpEPSG:3857reprojectparcels_no_prj.shpNoneinvestigategrid_mislabelled.shpEPSG:4326?bounds in metresCount files by CRS before touching anything β€” the mix tells you what happenedFiles with no CRS need a decision, not a transformation spatialworkflow.io
The inventory is what turns a folder of unknowns into a work-list.

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']
What a CRS standardisation run has to report for the folder to be trustworthy afterwards. a standardised folder still needs a receiptOne row per filesource CRS, target, statusWhich files were relabellednot reprojected β€” a different actWhich files failedwith the error textBounds after, per filethe mislabel checkA folder standardised without a report is a folder somebody has to inspect by hand anyway spatialworkflow.io
The bounds column is what catches a file that was relabelled with the wrong CRS.

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

Per file, deciding between assigning a CRS and reprojecting. per file, one of three answersWhat does this file report for gdf.crs?target CRSCopy as isnothing to doanother CRSReprojectto_crs(target)NoneAssign, then reprojectonly if documentedNever assign the target CRS to a file that is not already in it spatialworkflow.io
The third branch is the one that silently relocates data when it is guessed.

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

Expected total_bounds ranges for degrees, a metric national grid and Web Mercator. total_bounds tells you whether the CRS guess was rightEPSG:4326EPSG:27700EPSG:3857x range-180 to 1800 to 700k+/- 20My range-90 to 900 to 1.3M+/- 20Ma wrong guess readsβœ•huge valuesβœ•tiny valuesβœ•off by 10^5Print total_bounds after every set_crs and to_crs, before the next step spatialworkflow.io
A bounds value far outside these ranges catches a wrong guess before it propagates.

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.

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.