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?

Which CRS fix is neededEither layer .crs is None?set_crs() to label it (verifythe real CRS first)YesNoBoth valid but different?to_crs() one layer to match theotherYesNoRe-run the operationspatialworkflow.io
Deciding whether a CRS mismatch needs set_crs() (label) or to_crs() (reproject).

What a mismatch looks like

Symptoms of a CRS mismatch and what each one points to. how a mismatch shows itselfWhat you seeWhat it meansSpatial join returns zero rowsThe layers do not overlap incoordinate spaceLayers plot kilometres apartDifferent CRSs β€” reproject one tothe otherAreas are absurdly small numbersYou are measuring in degreesFeatures land in the Gulf ofGuineaCoordinates near 0,0 β€” CRS lost orswapped spatialworkflow.io
None of these raise an exception β€” that is what makes them dangerous.

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.

Herefordshire food businesses matched to a ward by a spatial join, with the CRS mismatched, mislabelled with set_crs, and reprojected with to_crs. 1,363 points joined to 53 wardsCRSs differ0 matchedset_crs(27700, override)0 matchedto_crs(27700)1,363 matched07001400The first warns once; the second raises nothing, because the labels now agree spatialworkflow.io
Only a transformation moves the points; a label change just stops the warning.

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:

Deciding which layer to reproject when two layers disagree. which layer should move?Two layers, two CRSs β€” which one do youreproject?measuringBoth β†’ projectedmetres for areajoiningSmall β†’ largeless data to transformpublishingBoth β†’ EPSG:4326web and exchangeReproject a copy, never the original β€” and reproject once, early spatialworkflow.io
The target CRS follows from what you are about to do, not from which file is bigger.

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-NaN result and carries on. An explicit assert left.crs == right.crs before 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:4326 removes the mismatch but leaves buffers, areas and distances in degrees.

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.