How to Set, Assign and Convert CRS in GeoPandas (set_crs vs to_crs)
On this page
Problem statement
Two methods, one letter apart, and they do opposite things:
gdf.set_crs(27700) # says "these numbers are already British National Grid"
gdf.to_crs(27700) # says "convert these numbers into British National Grid"
Call the wrong one and there is no error. The data is simply somewhere else:
import geopandas as gpd
gdf = gpd.read_file("points.geojson") # railway station entrances in West Yorkshire
print(gdf.crs)
print(gdf.geometry.iloc[0])
wrong = gdf.set_crs(27700, allow_override=True)
print(wrong.geometry.iloc[0], "← unmoved")
print(wrong.to_crs(4326).geometry.iloc[0], "← where GeoPandas now thinks it is")
EPSG:4326
POINT (-1.547091266029703 53.79497431122394)
POINT (-1.547091266029703 53.79497431122394) ← unmoved
POINT (-7.557236416365297 49.76728776978091) ← where GeoPandas now thinks it is
The coordinates never changed; only the label did. So GeoPandas now believes that station entrance is about 1.8 m west and 53.8 m north of the British National Grid's false origin — a spot in the sea south-west of the Isles of Scilly.
Then there is the third case, which is neither:
gdf.crs = 27700 # relabels like set_crs; overriding an existing CRS this way warns
Getting these right is the difference between data that is where it says it is and data that is confidently in the wrong place.
Quick answer
| Method | Coordinates | Label | Use when |
|---|---|---|---|
set_crs(epsg) |
unchanged | set | the CRS is missing and you know what it is |
set_crs(epsg, allow_override=True) |
unchanged | replaced | the recorded CRS is wrong |
to_crs(epsg) |
transformed | set to target | you want the data in a different CRS |
gdf.crs = epsg |
unchanged | set | warns when it overrides — use set_crs |
# the CRS is missing, and you know from the supplier it is BNG
gdf = gdf.set_crs(27700)
# the CRS is correct, and you want the data in a different one
gdf = gdf.to_crs(4326)
# the recorded CRS is wrong — relabel, then convert
gdf = gdf.set_crs(27700, allow_override=True).to_crs(4326)
The decisive question: are the numbers already in the target CRS? If yes, set_crs. If no, to_crs.
What you get — from 1. Establish what you have before touching anything
mystery.shp
crs None
bounds [393150.0, 408409.0, 449013.0, 447761.0]
sample POINT (429932 433280)
What you have, then relabel, test or convert
1. Establish what you have before touching anything
import geopandas as gpd
def crs_state(gdf, name=""):
print(f"{name}")
print(f" crs {gdf.crs}")
if gdf.crs is not None:
print(f" kind {'geographic' if gdf.crs.is_geographic else 'projected'}")
print(f" units {gdf.crs.axis_info[0].unit_name}")
print(f" bounds {gdf.total_bounds.round(4).tolist()}")
print(f" sample {gdf.geometry.iloc[0]}")
crs_state(gpd.read_file("mystery.shp"), "mystery.shp")
mystery.shp
crs None
bounds [393150.0, 408409.0, 449013.0, 447761.0]
sample POINT (429932 433280)
The bounds tell you almost everything. Six-figure numbers are metres in some projected system; values within ±180 and ±90 are degrees. This is a set_crs situation — the numbers are already in some CRS, and the file simply failed to record which.
A missing .prj sidecar is the usual cause with shapefiles, and it is a genuine information loss rather than a bug.
2. Use set_crs when the CRS is missing and you know it
gdf = gpd.read_file("mystery.shp")
print(gdf.crs)
before = gdf.total_bounds
gdf = gdf.set_crs(27700) # states what the numbers already are
print(gdf.crs, "— bounds unchanged:", (gdf.total_bounds == before).all())
None
EPSG:27700 — bounds unchanged: True
set_crs on a frame that already has a CRS raises, which is a useful safety net:
try:
gdf.set_crs(4326)
except ValueError as err:
print(f"ValueError: {err}")
ValueError: The GeoSeries already has a CRS which is not equal to the passed CRS. Specify 'allow_override=True' to allow replacing the existing CRS without doing any transformation. If you actually want to transform the geometries, use 'GeoSeries.to_crs' instead.
That message is unusually good: it names both options and says which one transforms.
3. Do not guess — test the hypothesis
Assigning a CRS is a factual claim. If it is wrong, everything downstream is wrong in a way nothing will catch, because the data is now internally consistent and consistently mislocated.
from shapely.geometry import box
def test_crs_hypothesis(gdf, epsg, reference, *, name=""):
"""Does labelling gdf as `epsg` put it inside a layer we trust?"""
candidate = gdf.set_crs(epsg, allow_override=True).to_crs(reference.crs)
ref = reference.union_all()
inside = candidate.geometry.representative_point().within(ref)
share = inside.mean()
ok = "✓" if share > 0.95 else "✗"
print(f" {ok} EPSG:{epsg:<6} {100 * share:>5.1f}% of features land inside "
f"{name or 'the reference layer'}")
return share
uk = gpd.read_file("uk_outline.gpkg") # a layer we trust
for epsg in [27700, 29903, 32630, 3857, 4326]:
test_crs_hypothesis(gdf, epsg, uk, name="the UK outline")
wy = gpd.read_file("west_yorkshire.gpkg") # a tighter one
for epsg in [27700, 29903]:
test_crs_hypothesis(gdf, epsg, wy, name="West Yorkshire")
✓ EPSG:27700 100.0% of features land inside the UK outline
✓ EPSG:29903 100.0% of features land inside the UK outline
✗ EPSG:32630 0.0% of features land inside the UK outline
✗ EPSG:3857 0.0% of features land inside the UK outline
✗ EPSG:4326 0.0% of features land inside the UK outline
✓ EPSG:27700 100.0% of features land inside West Yorkshire
✗ EPSG:29903 0.0% of features land inside West Yorkshire
Against the UK outline, two candidates put every station on land: British National Grid, and the Irish Grid, whose false origin is close enough that the same numbers land somewhere else in Britain. A coarse reference cannot tell them apart. Against West Yorkshire — the area the file is supposed to cover — only one survives. That is evidence, and it takes ten seconds — far better than a plausible-sounding guess, as long as the reference is tight enough to fail the wrong answer.
When nothing fits, do not pick the closest. Ask the supplier. A layer in an unidentified CRS is unusable, and an unidentified layer is more honest than a confidently mislabelled one.
4. Use to_crs to change coordinate systems
wards = gpd.read_file("wards.gpkg") # EPSG:4326
print([round(v, 3) for v in wards.geometry.iloc[0].bounds])
projected = wards.to_crs(27700)
print([round(v, 1) for v in projected.geometry.iloc[0].bounds])
[-1.582, 53.699, -1.481, 53.751]
[427679.4, 422595.2, 434340.5, 428415.4]
to_crs accepts an EPSG integer, an authority string, a PROJ string or a pyproj.CRS:
gdf.to_crs(27700)
gdf.to_crs("EPSG:27700")
gdf.to_crs("ESRI:54030")
gdf.to_crs("+proj=aea +lat_1=40 +lat_2=65 +lat_0=53 +lon_0=10 +datum=WGS84 +units=m")
gdf.to_crs(gdf.estimate_utm_crs())
gdf.to_crs(other_gdf.crs) # match another layer — the common case
to_crs(other.crs) is the idiom worth using by default when aligning two layers, because it cannot drift out of step with whatever the other layer actually is.
to_crs on a frame with no CRS raises, and correctly so:
try:
gpd.read_file("mystery.shp").to_crs(4326)
except ValueError as err:
print(f"ValueError: {err}")
ValueError: Cannot transform naive geometries. Please set a crs on the object first.
There is nothing to transform from — see cannot transform naive geometries.
5. Fix a wrong CRS with both, in order
The awkward case: the file records a CRS, and it is wrong.
bad = gpd.read_file("bad.gpkg")
print(bad.crs)
print(bad.total_bounds.round(1).tolist())
EPSG:4326
[413215.8, 422595.2, 446878.7, 450177.2]
Six-figure coordinates labelled as degrees. The label is wrong; the numbers are fine.
# ❌ transforms from a CRS the data was never in
try:
broken = bad.to_crs(27700)
print("to_crs from the wrong label:", broken.total_bounds.round(1).tolist())
except Exception as err:
print(f"to_crs from the wrong label: {type(err).__name__}: {err}")
# ✅ correct the label first, then convert if needed
fixed = bad.set_crs(27700, allow_override=True)
wgs84 = fixed.to_crs(4326)
print("relabel, then convert: ", wgs84.total_bounds.round(3).tolist())
to_crs from the wrong label: [inf, inf, inf, inf]
relabel, then convert: [-1.8, 53.699, -1.29, 53.946]
The first attempt did not even raise. to_crs applies a transformation from the recorded CRS, the numbers are impossible as degrees, and PROJ handed back infinity for every coordinate — a frame that still has 33 rows and a CRS, and will fail somewhere far from here. The order matters and cannot be reversed: a wrong recorded CRS means a wrong transformation. set_crs(..., allow_override=True) corrects the premise; only then is to_crs meaningful.
def repair_crs(gdf, actual_epsg, target_epsg=None):
"""Correct a wrongly recorded CRS, then optionally convert."""
out = gdf.set_crs(actual_epsg, allow_override=True)
return out.to_crs(target_epsg) if target_epsg else out
6. Know what each one costs
import time
parcels = gpd.read_file("parcels.gpkg") # 293,729 Leeds land titles, EPSG:27700
t0 = time.perf_counter(); a = parcels.set_crs(3857, allow_override=True)
print(f"set_crs {time.perf_counter() - t0:.4f} s")
t0 = time.perf_counter(); b = parcels.to_crs(3857)
print(f"to_crs {time.perf_counter() - t0:.4f} s")
set_crs 0.0023 s
to_crs 2.6186 s
set_crs writes one attribute. to_crs transforms every coordinate of every geometry and constructs a new Shapely object for each — which is why it belongs once at the boundary of a pipeline and never inside a loop.
to_crs transforms from whatever the label says. Fix the premise before converting.A safe wrapper, and what going wrong looks like
The data used on this page
Everything here is built from real layers, and two of the files are damaged on purpose, because the damage is what this page is about. points.geojson is the railway station entrances of West Yorkshire from the Department for Transport's NaPTAN. mystery.shp is the same entrances in British National Grid with the .prj file deleted — the commonest way a CRS goes missing. wards.gpkg is Leeds' 33 wards from the ONS in WGS 84, and bad.gpkg is those wards in British National Grid labelled as WGS 84. uk_outline.gpkg is the ONS's UK country boundaries and west_yorkshire.gpkg the five West Yorkshire districts dissolved — the trusted references — and parcels.gpkg is HM Land Registry's INSPIRE polygons for Leeds. Land Registry data © Crown copyright and database right 2026, Open Government Licence v3.0.
import io
import os
import geopandas as gpd
import pandas as pd
import pyogrio
import requests
# NaPTAN area 450 as saved on 9 October 2026 (the register changes weekly); live:
# naptan.api.dft.gov.uk/v1/access-nodes?dataFormat=csv&atcoAreaCodes=450
naptan = pd.read_csv(io.StringIO(requests.get("https://data.spatialworkflow.io/naptan-area-450-2026-10-09.csv", timeout=300).text),
low_memory=False)
naptan = naptan[(naptan["Status"] == "active") & (naptan["StopType"] == "RSE")]
stations = gpd.GeoDataFrame(naptan[["ATCOCode", "CommonName"]].reset_index(drop=True), crs=27700,
geometry=gpd.points_from_xy(naptan["Easting"], naptan["Northing"]))
stations.to_crs(4326).to_file("points.geojson")
stations.to_file("mystery.shp")
os.remove("mystery.prj") # the CRS goes missing
ONS = "https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services"
wards = gpd.read_file(f"{ONS}/Wards_December_2024_Boundaries_UK_BFC/FeatureServer/0/query"
"?where=LAD24NM%3D%27Leeds%27&outFields=WD24CD,WD24NM&outSR=4326&f=geojson")
wards.to_file("wards.gpkg")
wards.to_crs(27700).set_crs(4326, allow_override=True).to_file("bad.gpkg") # metres labelled as degrees
uk = gpd.read_file(f"{ONS}/Countries_December_2024_Boundaries_UK_BUC/FeatureServer/0/query"
"?where=1%3D1&outFields=CTRY24NM&outSR=27700&f=geojson").set_crs(27700, allow_override=True)
uk.to_file("uk_outline.gpkg")
districts = gpd.read_file(f"{ONS}/Local_Authority_Districts_DEC_2025_Boundaries_UK_BGC/FeatureServer/0/query"
"?where=LAD25NM+IN+(%27Leeds%27,%27Bradford%27,%27Calderdale%27,%27Kirklees%27,%27Wakefield%27)"
"&outFields=LAD25NM&outSR=27700&f=geojson").set_crs(27700, allow_override=True)
districts.dissolve().to_file("west_yorkshire.gpkg")
# HM Land Registry's download sets a cookie and redirects to itself, so use a session
reply = requests.Session().get("https://use-land-property-data.service.gov.uk/datasets/inspire/download/Leeds.zip",
timeout=600)
reply.raise_for_status()
with open("leeds_inspire.zip", "wb") as f:
f.write(reply.content)
pyogrio.set_gdal_config_options({"GML_DOWNLOAD_SCHEMA": "NO"}) # the file's schema URL is unreachable
titles = gpd.read_file("zip://leeds_inspire.zip!Land_Registry_Cadastral_Parcels.gml", columns=["INSPIREID"])
titles.to_file("parcels.gpkg")
print(f"{len(stations)} station entrances, {len(wards)} wards, {len(uk)} countries, {len(titles):,} titles")
155 station entrances, 33 wards, 4 countries, 293,729 titles
Example 1: a safe wrapper that will not let you confuse them
import geopandas as gpd
from pyproj import CRS
def assign_crs(gdf, epsg, *, override=False, verify_against=None):
"""State what the coordinates already are. Never moves anything."""
target = CRS.from_user_input(epsg)
if gdf.crs is not None and not override:
if gdf.crs == target:
return gdf
raise ValueError(
f"the layer is already labelled {gdf.crs.to_string()}. "
f"If that label is wrong, pass override=True. "
f"If you want to CONVERT to {target.to_string()}, use convert_crs().")
before = tuple(gdf.total_bounds.round(4).tolist())
out = gdf.set_crs(target, allow_override=True)
print(f"labelled as {target.to_string()} — coordinates unchanged {before}")
if verify_against is not None:
ref = verify_against.union_all()
inside = out.to_crs(verify_against.crs).geometry.representative_point().within(ref)
share = 100 * inside.mean()
print(f" {'✓' if share > 95 else '✗'} {share:.1f}% of features fall inside "
f"the reference layer")
if share < 50:
raise ValueError(
f"only {share:.1f}% of features land inside the reference — "
f"{target.to_string()} is probably not the right CRS")
return out
def convert_crs(gdf, epsg):
"""Transform coordinates into a different CRS."""
if gdf.crs is None:
raise ValueError(
"the layer has no CRS, so there is nothing to convert FROM. "
"Use assign_crs() to state what the coordinates already are.")
target = CRS.from_user_input(epsg)
if gdf.crs == target:
return gdf
before = tuple(gdf.total_bounds.round(2).tolist())
out = gdf.to_crs(target)
print(f"{gdf.crs.to_string()} → {target.to_string()}")
print(f" bounds {before}")
print(f" → {tuple(out.total_bounds.round(2).tolist())}")
return out
uk = gpd.read_file("uk_outline.gpkg")
mystery = assign_crs(gpd.read_file("mystery.shp"), 27700, verify_against=uk)
wgs84 = convert_crs(mystery, 4326)
labelled as EPSG:27700 — coordinates unchanged (393150.0, 408409.0, 449013.0, 447761.0)
✓ 100.0% of features fall inside the reference layer
EPSG:27700 → EPSG:4326
bounds (393150.0, 408409.0, 449013.0, 447761.0)
→ (-2.11, 53.57, -1.26, 53.93)
Two things make this hard to misuse. The names say what they do — "assign" and "convert" rather than two verbs a letter apart — and each error message points at the other function by name.
The verification step is what turns a guess into a claim with evidence. Raising when fewer than half the features land inside the reference catches the mislabelling before it propagates.
Example 2: what going wrong actually looks like
import geopandas as gpd
from shapely.geometry import Point
from pyproj import Geod
geod = Geod(ellps="WGS84")
truth = Point(-1.5474, 53.7952) # Leeds station, WGS 84
gdf = gpd.GeoDataFrame({"name": ["Leeds station"]}, geometry=[truth], crs=4326)
def where_does_it_end_up(gdf, operation, label):
out = operation(gdf)
back = out.to_crs(4326).geometry.iloc[0]
_, _, metres = geod.inv(truth.x, truth.y, back.x, back.y)
print(f"{label:<44} {back.x:>9.4f}, {back.y:>8.4f} "
f"{metres / 1000:>10,.1f} km away")
where_does_it_end_up(gdf, lambda g: g.to_crs(27700), "to_crs(27700) — correct")
where_does_it_end_up(gdf, lambda g: g.set_crs(27700, allow_override=True),
"set_crs(27700, override) — wrong")
where_does_it_end_up(gdf, lambda g: g.to_crs(27700).set_crs(4326, allow_override=True),
"to_crs then wrongly relabelled")
where_does_it_end_up(gdf, lambda g: g.to_crs(3857).to_crs(27700).to_crs(4326),
"round trip through 3857 — lossy but correct")
to_crs(27700) — correct -1.5474, 53.7952 0.0 km away
set_crs(27700, override) — wrong -7.5572, 49.7673 610.2 km away
to_crs then wrongly relabelled 429911.5038, 433304.9800 nan km away
round trip through 3857 — lossy but correct -1.5474, 53.7952 0.0 km away
Four outcomes worth reading. The correct call round-trips exactly. Mislabelling with set_crs moves the point 610.2 km, out to sea. Labelling projected metres as degrees produces coordinates of about 429,900 and 433,300 "degrees", far outside the valid range, and what comes back depends on how the transformation copes with impossible input — nothing sensible.
The last row is reassurance: a legitimate round trip through Web Mercator returns to within floating-point noise. Reprojection is lossy in precision, not in position — the damage in rows two and three comes entirely from a wrong label, not from the transformation.
Writing this test with a known point and a geodesic distance is the fastest way to confirm any CRS handling in a pipeline.
Two meanings of 'the CRS of this data'
The confusion between set_crs and to_crs comes from a genuine ambiguity in what "the CRS of this data" means, and it is worth separating the two things it can mean.
A GeoDataFrame holds two independent pieces of information: the coordinate numbers and the CRS attribute describing what those numbers mean. Most of the time they agree, and it is easy to think of them as one thing. They are not, and every problem here comes from an operation that changes one without the other.
set_crs changes the label only. It asserts that the existing numbers are already in that system — a statement about the past, about how the data was produced. It is the right operation when a file lost its CRS and you know from the supplier what it was.
to_crs changes the numbers, computing new coordinates that identify the same place in a different system, and updates the label to match. It preserves the meaning and changes the representation.
Seen that way, the failure modes are obvious. set_crs with a wrong CRS produces data that is internally consistent and describes the wrong place — no exception, because nothing is contradictory. to_crs from a wrong recorded CRS applies a transformation from a system the data was never in, producing coordinates that are wrong by however much the two systems differ.
The allow_override guard exists because of that second failure. Overwriting a recorded CRS is a claim that the file's own metadata is wrong, which is occasionally true and usually not. Requiring an explicit flag makes it a decision rather than an accident, and the error GeoPandas raises names both alternatives. Fixing a whole folder of disagreeing labels the same way, with a report of what changed, is standardising CRS across a folder of files.
Attribute assignment, copies and equivalent CRSs
gdf.crs = epsgrelabels likeset_crs, silently on a frame with no CRS; overriding an existing one raises aDeprecationWarning(GeoPandas 1.1). Use the method.set_crsreturns a new object by default. Passinplace=Trueor reassign.- Equivalent CRS can compare unequal when their WKT differs. Compare with
CRS.from_user_input(a).equals(b). - EPSG:4326 and EPSG:4979 differ only in dimensionality (2-D versus 3-D) and compare unequal.
Internal links
- How to fix CRS mismatch in GeoPandas — when two layers disagree
- Cannot transform naive geometries to CRS — what
to_crsraises with no CRS - Projected vs geographic CRS — what changes when you convert
- How to reproject spatial data in Python (GeoPandas) — the conversion in depth
- How to standardise and repair CRS across a folder of files — the folder-scale version
- Raster and vector do not line up in Python — the symptom of getting this wrong
- How to choose the right projected CRS for your study area — deciding what to convert to
FAQ
Which one do I need?
Ask whether the numbers are already in the target CRS. If they are, set_crs. If they need converting, to_crs.
My file has no CRS. What do I do?
set_crs, but only once you know what it is. Test the hypothesis by relabelling and checking the features land inside a layer you trust — do not guess.
The recorded CRS is wrong. How do I fix it?
set_crs(correct, allow_override=True) first, then to_crs(target) if you also need to convert. The order cannot be reversed, because to_crs starts from the recorded label.
Why does set_crs raise when a CRS is already set?
Because overwriting it is a claim that the file's own metadata is wrong. That is sometimes true and usually not, so it requires allow_override=True to make it deliberate.
Is gdf.crs = 27700 the same as set_crs?
Effectively: it relabels without transforming. On a frame that already has a CRS it overrides it with only a DeprecationWarning, where set_crs refuses unless you pass allow_override=True. Use set_crs, which is explicit about both.
Why is to_crs so slow?
It transforms every coordinate and builds a new geometry per feature — 2.5 seconds for Leeds' 293,729 land titles, against 0.003 for set_crs. Reproject once at the boundary of your pipeline, never inside a loop.