How to Fix CRS Mismatch in GeoPandas
On this page
Problem statement
A spatial join of 1,363 Herefordshire food businesses against the county's 53 wards matches 0 of them, although every business sits inside a ward. The businesses came from a GeoJSON in longitude and latitude; the wards came from a shapefile in British National Grid metres. GeoPandas compared longitudes of β3.125 to β2.342 with eastings of 321,926 to 376,941 as though they measured the same thing, printed a single UserWarning, and returned the empty result.
That is what a CRS mismatch looks like: no exception, just a join, clip or overlay that quietly finds nothing, or a plot where one layer is a speck at the edge of the other. This page shows how to spot it, which layer to move, and the one "fix" that hides the warning without fixing anything.
If a layer has no CRS at all (.crs is None), that is a different problem with its own page: Cannot transform naive geometries.
Quick answer
Compare .crs on both layers, then reproject the secondary layer with to_crs() so it matches the layer you are analysing against.
import geopandas as gpd
points = gpd.read_file("data/customer_points.geojson")
polygons = gpd.read_file("data/service_zones.shp")
print(points.crs)
print(polygons.crs)
# Example: reproject points to match polygons
points = points.to_crs(polygons.crs)
print(points.crs, points.total_bounds.round(0))
EPSG:4326
EPSG:27700
EPSG:27700 [323022. 214629. 376696. 274065.]
Which fix does the mismatch need?
What a mismatch looks like
Check whether your GeoDataFrames use different CRS
Before a join, overlay, clip or distance calculation, print both CRSs side by side.
import geopandas as gpd
points = gpd.read_file("data/customer_points.geojson")
zones = gpd.read_file("data/service_zones.shp")
print("Points CRS:", points.crs)
print("Zones CRS:", zones.crs)
Points CRS: EPSG:4326
Zones CRS: EPSG:27700
Confirm it with the coordinates, not just the labels
ax = zones.plot(facecolor="none", edgecolor="black", figsize=(8, 8))
points.plot(ax=ax, color="red", markersize=10)
print("zones span", zones.total_bounds.round(0), "| points span", points.total_bounds.round(3))
zones span [321926. 214365. 376941. 277841.] | points span [-3.125 51.828 -2.342 52.361]
The zones run to hundreds of thousands of metres east and north; the points sit between β3.1 and β2.3 and 51.8 and 52.4. Read as metres, those points are within about 50 metres of the British National Grid origin, in the Atlantic south-west of the Isles of Scilly. Comparing total_bounds is the fastest test there is: two layers of the same county should have bounds of the same magnitude.
The join that runs anyway
import warnings
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
joined = gpd.sjoin(points, zones, predicate="within", how="left")
print(f"{joined['index_right'].notna().sum()} of {len(points):,} points matched a zone")
print("warning:", str(caught[0].message).split(".")[0] if caught else None)
0 of 1,363 points matched a zone
warning: CRS mismatch between the CRS of left geometries and the CRS of right geometries
GeoPandas warns once, as a UserWarning that is easy to lose in a notebook, and then runs the join on numbers that cannot meet. Zero matches from a join you expected to work is the signature of a mismatch.
Reproject one layer to match the other
Call to_crs() on the layer you are not analysing in, passing the other layer's CRS, and assign the result β it returns a new GeoDataFrame. The worked example below does this before a join; the next section shows how to check it took.
Joining Herefordshire's food businesses to wards
The data used on this page
Herefordshire. The points are the county's food businesses from the Food Standards Agency's register, in longitude and latitude β as GeoJSON, and as a plain CSV with lon and lat columns. The zones are the county's 53 electoral wards from the ONS (December 2024), saved as shapefiles in British National Grid β with the ward name as district, because a shapefile truncates column names to ten characters β and the boundary is the county itself. The register is a copy of the API's answer saved on 10 October 2026, so it stays the one measured here.
import os
import geopandas as gpd
import pandas as pd
import requests
os.makedirs("data", exist_ok=True)
ONS = "https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/{}/FeatureServer/0/query"
if not os.path.exists("data/customer_points.geojson"):
# live: requests.get("https://api.ratings.food.gov.uk/Establishments", headers={"x-api-version": "2"},
# params={"localAuthorityId": 383, "pageSize": 5000}, timeout=300) # Herefordshire
# read instead as the API answered on 10 October 2026, so the register stays put
reply = requests.get("https://data.spatialworkflow.io/fhrs-api-383-2026-10-10.json", timeout=300)
est = pd.DataFrame(reply.json()["establishments"])
est["lat"] = est["geocode"].map(lambda g: float(g["latitude"]) if g and g.get("latitude") else None)
est["lon"] = est["geocode"].map(lambda g: float(g["longitude"]) if g and g.get("longitude") else None)
est = est.dropna(subset=["lat", "lon"])
stores = gpd.GeoDataFrame({"store_name": est["BusinessName"], "type": est["BusinessType"]},
geometry=gpd.points_from_xy(est["lon"], est["lat"]), crs=4326)
stores.to_file("data/customer_points.geojson")
stores.to_file("data/stores.geojson")
est[["BusinessName", "lon", "lat"]].to_csv("data/field_points.csv", index=False)
wards = gpd.read_file(ONS.format("Wards_December_2024_Boundaries_UK_BGC")
+ "?where=LAD24CD%3D%27E06000019%27&outFields=WD24CD,WD24NM&outSR=4326&f=geojson")
wards = wards.to_crs(27700).rename(columns={"WD24CD": "zone_id", "WD24NM": "district"})
wards.to_file("data/service_zones.shp")
wards.to_file("data/districts.shp")
gpd.read_file(ONS.format("Local_Authority_Districts_December_2024_Boundaries_UK_BGC")
+ "?where=LAD24CD%3D%27E06000019%27&outFields=LAD24NM&outSR=4326&f=geojson"
).to_crs(27700).to_file("data/project_boundary.shp")
for name in ("customer_points.geojson", "service_zones.shp", "project_boundary.shp"):
layer = gpd.read_file("data/" + name)
print(f"{name:24} {len(layer):>6,} features {layer.crs}")
customer_points.geojson 1,363 features EPSG:4326
service_zones.shp 53 features EPSG:27700
project_boundary.shp 1 features EPSG:27700
Example: fix CRS mismatch before a spatial join
import geopandas as gpd
# Load layers from different sources
points = gpd.read_file("data/stores.geojson")
districts = gpd.read_file("data/districts.shp")
print("Before fix")
print("Points CRS:", points.crs)
print("Districts CRS:", districts.crs)
# If points had no CRS but you confirmed they were in EPSG:4326:
# points = points.set_crs("EPSG:4326")
# Reproject points to match polygon CRS
points = points.to_crs(districts.crs)
print("\nAfter fix")
print("Points CRS:", points.crs)
print("Districts CRS:", districts.crs)
# Run spatial join
stores_with_districts = gpd.sjoin(points, districts, predicate="within", how="left")
print(stores_with_districts[["store_name", "district"]].head())
print(stores_with_districts["district"].notna().sum(), "of", len(points), "stores in a district")
Before fix
Points CRS: EPSG:4326
Districts CRS: EPSG:27700
After fix
Points CRS: EPSG:27700
Districts CRS: EPSG:27700
store_name district
0 35 West Ltd Leominster South
1 59 The Bakery Kington
2 A H Griffiths Ltd (Butcher) Mortimer
3 A Mews Bouche Ledbury North
4 A Rule of Tum Burger Shop Ltd Central
1363 of 1363 stores in a district
Verify that the CRS fix worked
Compare CRS after conversion
points = gpd.read_file("data/customer_points.geojson").to_crs(zones.crs)
print("Points CRS:", points.crs)
print("Zones CRS:", zones.crs)
Points CRS: EPSG:27700
Zones CRS: EPSG:27700
Plot both layers to check alignment
ax = zones.plot(facecolor="none", edgecolor="black", figsize=(8, 8))
points.plot(ax=ax, color="red", markersize=10)
Re-run the failed spatial operation
joined = gpd.sjoin(points, zones, predicate="within", how="left")
print(joined[["zone_id"]].head())
print(joined["zone_id"].notna().sum(), "of", len(points), "points matched a zone;",
joined["zone_id"].nunique(), "zones used")
zone_id
0 E05009470
1 E05009464
2 E05009473
3 E05009465
4 E05009449
1363 of 1363 points matched a zone; 53 zones used
Every point now lands in a ward, and all 53 wards are used β a quick sign the points were spread across the county rather than piled at one edge.
Why GeoPandas only warns
The warning compares labels. Nothing in GeoPandas compares the coordinates with their label, so the warning can be silenced by changing the label alone:
# Wrong if the data is already in EPSG:4326 and you want EPSG:27700
lonlat = gpd.read_file("data/customer_points.geojson")
mislabelled = lonlat.set_crs("EPSG:27700", allow_override=True)
print(mislabelled.crs, mislabelled.total_bounds.round(3))
print(gpd.sjoin(mislabelled, zones, predicate="within")["zone_id"].count(), "matches")
EPSG:27700 [-3.125 51.828 -2.342 52.361]
0 matches
set_crs(..., allow_override=True) rewrites the label and leaves the numbers alone. The CRSs now match, so the warning disappears β and every point is still a longitude and latitude pretending to be metres, 53 metres from the grid's origin. No error, no warning, zero matches. The transformation is the fix:
points = lonlat.to_crs("EPSG:27700")
print(points.total_bounds.round(0), gpd.sjoin(points, zones, predicate="within")["zone_id"].count(), "matches")
[323022. 214629. 376696. 274065.] 1363 matches
Which layer to move follows from the operation, not from file size:
When the warning is not enough
- The warning is the only signal. In scripts that filter warnings, or in pipelines that log them where nobody reads, a mismatched join produces an empty or all-
NaNresult and carries on. An explicitassert left.crs == right.crsbefore the join turns the silent failure into a loud one. - Matching CRSs do not prove the data overlaps. If the labels agree and the layers still miss each other, compare
total_bounds: one file may simply cover somewhere else, or its label may have been wrong when it was written. - Metric work needs a projected CRS on both sides. Aligning two layers on
EPSG:4326removes the mismatch but leaves buffers, areas and distances in degrees.
Internal links
- How to Set, Assign and Convert CRS in GeoPandas (set_crs vs to_crs)
- Cannot Transform Naive Geometries to CRS: How to Fix It in GeoPandas
- GeoPandas Not Reading Shapefile: Common Causes and Fixes
FAQ
What is the difference between set_crs() and to_crs() in GeoPandas?
set_crs() assigns CRS metadata without changing coordinates. to_crs() transforms coordinates into a new CRS. Use set_crs() for missing metadata, and to_crs() for actual reprojection.
Can I fix CRS mismatch if one file has no CRS defined?
Yes, but only if you know the correct source CRS. Assign it with set_crs(), then use to_crs() if you need to match another layer.
Does to_crs change my original GeoDataFrame in place?
No. to_crs() returns a new GeoDataFrame and leaves the original unchanged, so you must assign the result, as in points = points.to_crs(zones.crs). Forgetting the assignment is a common reason a "fixed" mismatch persists.
Should I reproject the smaller layer or the larger one to fix a mismatch?
Reproject whichever is the secondary layer to match your main analysis layer, and reprojecting the smaller layer is usually cheaper. For metric work like buffers or area, reproject both to a shared projected CRS rather than aligning on EPSG:4326.