How to Set, Assign and Convert CRS in GeoPandas (set_crs vs to_crs)

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:

gdf = gpd.read_file("points.geojson")
print(gdf.crs)                                  # EPSG:4326
print(gdf.geometry.iloc[0])                     # POINT (-2.2426 53.4808)

wrong = gdf.set_crs(27700, allow_override=True)
print(wrong.geometry.iloc[0])                   # POINT (-2.2426 53.4808)  ← unmoved
print(wrong.to_crs(4326).geometry.iloc[0])      # POINT (-7.556 49.766)    ← Atlantic

The coordinates never changed; only the label did. So GeoPandas now believes that point is 2.24 m west and 53.48 m north of the British National Grid origin β€” a spot off the Isles of Scilly, in the sea.

Then there is the third case, which is neither:

gdf.crs = 27700       # deprecated, and does the same as set_crs

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

Panels contrasting set_crs, which relabels coordinates, with to_crs, which transforms them.
One changes the label. The other changes the numbers. Only one of them moves the data to where you meant.
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 deprecated β€” 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.

Step-by-step solution

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   {[round(v, 4) for v in gdf.total_bounds]}")
    print(f"  sample   {gdf.geometry.iloc[0]}")

crs_state(gpd.read_file("mystery.shp"), "mystery.shp")
mystery.shp
  crs      None
  bounds   [351204.1, 381009.4, 407881.2, 445902.3]
  sample   POINT (351204.117 381009.882)

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)                    # None

gdf = gdf.set_crs(27700)          # states what the numbers already are
print(gdf.crs)                    # EPSG:27700
print(gdf.total_bounds)           # unchanged β€” nothing moved

set_crs on a frame that already has a CRS raises, which is a useful safety net:

gdf.set_crs(4326)
ValueError: The GeoDataFrame already has a CRS which is not equal to the passed
CRS. Specify 'allow_override=True' to replace the existing CRS without doing any
transformation. If you actually want to transform the geometries, use
'GeoDataFrame.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")
  βœ“ EPSG:27700   100.0% of features land inside the UK outline
  βœ— EPSG:29903     0.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

One candidate places every feature where it should be. That is evidence, and it takes ten seconds β€” far better than a plausible-sounding guess.

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

gdf = gpd.read_file("wards.gpkg")     # EPSG:4326
print(gdf.geometry.iloc[0].bounds)    # (-2.29, 53.44, -2.19, 53.51)

projected = gdf.to_crs(27700)
print(projected.geometry.iloc[0].bounds)   # (383104.2, 396882.1, 389442.8, 404118.9)

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:

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.

gdf = gpd.read_file("bad.gpkg")
print(gdf.crs)                        # EPSG:4326
print(gdf.total_bounds)               # [351204.1, 381009.4, 407881.2, 445902.3]

Six-figure coordinates labelled as degrees. The label is wrong; the numbers are fine.

# ❌ transforms from a CRS the data was never in
fixed = gdf.to_crs(27700)

# βœ… correct the label first, then convert if needed
fixed = gdf.set_crs(27700, allow_override=True)
wgs84 = fixed.to_crs(4326)

The order matters and cannot be reversed. to_crs applies a transformation from the recorded CRS, so 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

gdf = gpd.read_file("parcels.gpkg")     # 4 million features

t0 = time.perf_counter(); a = gdf.set_crs(27700, allow_override=True)
print(f"set_crs  {time.perf_counter() - t0:.4f} s")

t0 = time.perf_counter(); b = gdf.to_crs(3857)
print(f"to_crs   {time.perf_counter() - t0:.4f} s")
set_crs  0.0021 s
to_crs   41.8820 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.

Vertical steps showing that a wrong label must be corrected before any conversion.
`to_crs` transforms from whatever the label says. Fix the premise before converting.

Code examples

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(round(v, 4) for v in gdf.total_bounds)
    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(round(v, 2) for v in gdf.total_bounds)
    out = gdf.to_crs(target)
    print(f"{gdf.crs.to_string()} β†’ {target.to_string()}")
    print(f"  bounds {before}")
    print(f"      β†’  {tuple(round(v, 2) for v in out.total_bounds)}")
    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 (351204.1, 381009.4, 407881.2, 445902.3)
  βœ“ 100.0% of features fall inside the reference layer
EPSG:27700 β†’ EPSG:4326
  bounds (351204.1, 381009.4, 407881.2, 445902.3)
      β†’  (-2.7, 53.34, -1.91, 53.9)

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: standardising a folder where every file disagrees

from pathlib import Path
from collections import Counter
import geopandas as gpd
import pyogrio

def audit_crs(folder, pattern="*.gpkg"):
    rows = []
    for path in sorted(Path(folder).glob(pattern)):
        info = pyogrio.read_info(path)
        bounds = info.get("total_bounds")
        crs = info.get("crs")
        looks_like = ("degrees" if bounds is not None
                      and abs(bounds[0]) <= 180 and abs(bounds[1]) <= 90
                      else "metres")
        rows.append({"file": path.name, "declared": crs, "looks_like": looks_like,
                     "bounds": None if bounds is None
                               else tuple(round(v, 1) for v in bounds)})

    print(f"{'file':<26}{'declared':<18}{'looks like':<12}bounds")
    for r in rows:
        suspicious = (r["declared"] and "4326" in str(r["declared"])
                      and r["looks_like"] == "metres")
        mark = "βœ—" if suspicious or not r["declared"] else " "
        print(f"{mark}{r['file']:<25}{str(r['declared'])[:17]:<18}"
              f"{r['looks_like']:<12}{r['bounds']}")
    print(f"\ndeclared CRS: {dict(Counter(str(r['declared']) for r in rows))}")
    return rows

def standardise(folder, out_dir, target=27700, *, assume=None, reference=None):
    """Bring a folder to one CRS, repairing wrong labels on the way."""
    out_dir = Path(out_dir); out_dir.mkdir(parents=True, exist_ok=True)
    results = []
    for path in sorted(Path(folder).glob("*.gpkg")):
        gdf = gpd.read_file(path)
        note = ""
        try:
            if gdf.crs is None:
                if assume is None:
                    results.append({"file": path.name, "status": "skipped",
                                    "note": "no CRS and no assumption given"})
                    continue
                gdf = gdf.set_crs(assume)
                note = f"assumed EPSG:{assume}"

            # a declared geographic CRS with projected-looking bounds is wrong
            elif gdf.crs.is_geographic and abs(gdf.total_bounds[0]) > 180:
                if assume is None:
                    results.append({"file": path.name, "status": "failed",
                                    "note": "declared CRS contradicts the bounds"})
                    continue
                gdf = gdf.set_crs(assume, allow_override=True)
                note = f"relabelled {gdf.crs} β†’ EPSG:{assume}"

            out = gdf.to_crs(target)

            if reference is not None:
                share = out.geometry.representative_point().within(
                    reference.to_crs(target).union_all()).mean()
                if share < 0.5:
                    results.append({"file": path.name, "status": "failed",
                                    "note": f"only {100*share:.0f}% inside reference"})
                    continue

            out.to_file(out_dir / path.name, driver="GPKG")
            results.append({"file": path.name, "status": "ok", "note": note})
        except Exception as exc:
            results.append({"file": path.name, "status": "failed",
                            "note": f"{type(exc).__name__}: {exc}"[:80]})

    marks = {"ok": "βœ“", "skipped": "Β·", "failed": "βœ—"}
    for r in results:
        print(f"  {marks[r['status']]} {r['file']:<28} {r['note']}")
    return results

audit_crs("raw/")
standardise("raw/", "standardised/", target=27700, assume=27700,
            reference=gpd.read_file("uk_outline.gpkg"))
file                      declared          looks like  bounds
 wards.gpkg               EPSG:27700        metres      (351204.1, 381009.4, …)
 roads.gpkg               EPSG:4326         degrees     (-2.7, 53.3, -1.9, 53.9)
βœ—parcels.gpkg             EPSG:4326         metres      (351204.1, 381009.4, …)
βœ—sites.gpkg               None              metres      (351204.1, 381009.4, …)

  βœ“ wards.gpkg
  βœ“ roads.gpkg
  βœ“ parcels.gpkg                relabelled EPSG:4326 β†’ EPSG:27700
  βœ“ sites.gpkg                  assumed EPSG:27700

The audit's key check is the contradiction between the declared CRS and the coordinate magnitudes. A file declaring EPSG:4326 with six-figure bounds is definitely mislabelled β€” degrees cannot exceed 180 β€” and that inference is safe enough to act on.

Note that assume is required rather than inferred. The audit can prove a label is wrong; it cannot prove what the right one is. Making the user supply it keeps a guess from being buried inside a helper.

Example 3: 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(-2.2426, 53.4808)                     # Manchester, WGS 84
gdf = gpd.GeoDataFrame({"name": ["Manchester"]}, 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                        -2.2426,  53.4808          0.0 km away
set_crs(27700, override) β€” wrong               -7.5560,  49.7660        620.4 km away
to_crs then wrongly relabelled                  0.0000,   0.0000     6,041.2 km away
round trip through 3857 β€” lossy but correct    -2.2426,  53.4808          0.0 km away

Four outcomes worth reading. The correct call round-trips exactly. Mislabelling with set_crs moves the point 620 km into the Atlantic. Labelling projected metres as degrees puts it near the intersection of the equator and the prime meridian β€” coordinates of 383,618 and 398,050 are far outside the valid degree range, and everything downstream then clamps or wraps.

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.

Explanation

Stack showing the coordinate numbers and the CRS attribute as two separate pieces of state.
Two independent facts. Every problem here is an operation that changes one without the other.

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.

This is also why order matters when repairing a wrong label. to_crs reads the current label as its starting point. If that label is wrong, the transformation starts from the wrong place, and no subsequent operation can recover. Correcting the label first β€” set_crs(..., allow_override=True) β€” fixes the premise, after which to_crs is meaningful again.

The allow_override guard exists precisely because of this. 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 message GeoPandas raises names both alternatives β€” one of the better error messages in the library.

Finally, the cost asymmetry is worth internalising. set_crs writes one attribute and is effectively free. to_crs transforms every coordinate of every geometry, allocating a new Shapely object per feature β€” 42 seconds for 4 million parcels. That is why the standard advice is reproject once, at the boundary of your pipeline: read, immediately convert to the analysis CRS, do all the work, convert once more on the way out. Calling to_crs inside a loop is one of the most reliable ways to make a GIS script slow, as covered in why GeoPandas is slow.

Edge cases or notes

  • gdf.crs = epsg is deprecated and behaves like set_crs. Use the method.
  • set_crs on a frame that already has a CRS raises unless allow_override=True. That guard is deliberate.
  • to_crs on a frame with no CRS raises β€” there is nothing to transform from.
  • to_crs(other.crs) is the safest way to align two layers, because it cannot drift out of step.
  • Six-figure coordinates labelled EPSG:4326 are definitely wrong. Degrees cannot exceed 180.
  • set_crs returns a new object by default. Pass inplace=True or 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.
  • to_crs is expensive β€” once per pipeline, never in a loop.
  • A shapefile's CRS lives in a .prj sidecar, which is the usual reason one goes missing.

FAQ

What is the difference in one sentence?

set_crs changes the label and leaves the coordinates alone; to_crs transforms the coordinates and updates the label to match.

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, and it is deprecated. Use set_crs, which is explicit about not transforming.

Why is to_crs so slow?

It transforms every coordinate and builds a new geometry per feature β€” around 42 seconds for 4 million parcels. Reproject once at the boundary of your pipeline, never inside a loop.