Assign a CRS Without Reprojecting in PyQGIS
A layer's CRS is a label: it tells QGIS how to interpret the numbers in the geometry. When the label is wrong — a shapefile with the wrong .prj, a CSV loaded with the project's default CRS, an export that wrote WGS 84 over projected metres — the data appears in the wrong place, often in the ocean off West Africa or as a speck near the origin. Reprojecting such a layer makes things worse: it transforms coordinates that were never in the source system, moving the data further from the truth. The fix is to change the label and leave the numbers alone.
This recipe belongs to Coordinate Reference Systems. It explains the difference between assigning and reprojecting, recognises mislabelled layers from their coordinates, tests candidate CRSs against a known location, relabels the layer for the session and permanently, and adds checks that stop the mistake recurring. Layers with no CRS at all are covered in handling missing CRS.
Prerequisites
- QGIS 3.34 LTR or newer, or the QGIS 4 series.
- Some idea of where the data should be: a known town, a reference layer, the survey area. Diagnosing a CRS without one is guesswork.
Recognise a mislabelled layer
The coordinates themselves usually reveal the true system. Geographic coordinates lie between −180 and 180, −90 and 90; projected coordinates are hundreds of thousands or millions of metres, often with recognisable ranges.
from qgis.core import QgsProject
def coordinate_fingerprint(layer):
e = layer.extent()
return {"crs_label": layer.crs().authid(),
"x": (round(e.xMinimum(), 3), round(e.xMaximum(), 3)),
"y": (round(e.yMinimum(), 3), round(e.yMaximum(), 3))}
def looks_geographic(e):
return -180 <= e.xMinimum() <= e.xMaximum() <= 180 and -90 <= e.yMinimum() <= e.yMaximum() <= 90
for lyr in QgsProject.instance().mapLayers().values():
if not hasattr(lyr, "isSpatial") or not lyr.isSpatial():
continue
e, crs = lyr.extent(), lyr.crs()
mismatch = crs.isGeographic() != looks_geographic(e)
flag = " <-- label does not match coordinates" if mismatch else ""
print(f"{lyr.name():<24} {coordinate_fingerprint(lyr)}{flag}")
Breakdown: A layer labelled geographic whose x values are in the hundreds of thousands, or labelled projected whose values are small decimals, is mislabelled — that test catches the commonest cases in one pass. Finer clues come from the ranges: UTM eastings lie roughly between 160,000 and 840,000 m; northings in the northern hemisphere grow from 0 at the equator to about 9,300,000 m; national grids have characteristic offsets, such as a leading zone digit in older German Gauss–Krüger coordinates (3,4xx,xxx). Recognising them narrows the candidates quickly.
Test candidate CRSs against a known location
With a short list of candidates, check each one: interpret the layer's coordinates in that CRS, transform a sample point to WGS 84, and see whether it lands where the data should be.
from qgis.core import (QgsCoordinateReferenceSystem, QgsCoordinateTransform,
QgsPointXY, QgsDistanceArea)
layer = QgsProject.instance().mapLayersByName("survey_points")[0]
sample = next(layer.getFeatures()).geometry().centroid().asPoint()
expected = QgsPointXY(9.993, 53.551) # roughly where the data should be (lon, lat)
wgs84 = QgsCoordinateReferenceSystem("EPSG:4326")
da = QgsDistanceArea()
da.setEllipsoid("EPSG:7030")
for code in ("EPSG:25832", "EPSG:25833", "EPSG:31467", "EPSG:3857", "EPSG:4326"):
cand = QgsCoordinateReferenceSystem(code)
xf = QgsCoordinateTransform(cand, wgs84, QgsProject.instance())
try:
p = xf.transform(sample)
except Exception:
print(f"{code:<11} cannot interpret these coordinates")
continue
km = da.measureLine(p, expected) / 1000
print(f"{code:<11} → {p.x():8.3f}, {p.y():7.3f} {km:10.1f} km from expected")
Breakdown: Each candidate interprets the same raw numbers; only the right one puts the sample near the expected location. Measuring the distance on the ellipsoid makes the comparison objective. A candidate that is close but tens or hundreds of metres off often shares the projection but not the datum — for example an old national datum versus its modern successor — and deserves a second look with a better reference point. Coordinates far outside a candidate's valid area raise transformation errors, which the try block reports instead of crashing.
Relabel the layer for the session
Once the right CRS is known, setting it on the layer moves the data to its correct position immediately, without touching a single coordinate.
correct = QgsCoordinateReferenceSystem("EPSG:25832")
layer.setCrs(correct)
layer.triggerRepaint()
print("now labelled", layer.crs().authid(), "; extent", layer.extent().toString(0))
Breakdown: setCrs changes the label only. The extent printed afterwards is numerically unchanged — the proof that nothing was transformed — but the layer now appears in the right place because QGIS interprets those numbers correctly. This change lives in the project; the file on disk still carries the wrong label, so the problem returns for anyone who opens the file directly.
Make the fix permanent
To fix the source for everyone, write the corrected label into the data. For formats with sidecar files the label can be rewritten; for others, writing a corrected copy is safer.
import processing
fixed = processing.run("native:assignprojection", {
"INPUT": layer, "CRS": correct, "OUTPUT": "memory:"})["OUTPUT"]
processing.run("native:savefeatures", {
"INPUT": fixed, "OUTPUT": "/data/fixed/survey_points.gpkg",
"LAYER_NAME": "survey_points"})
# shapefile: rewrite the .prj in place
prj = "/data/inbox/survey_points.prj"
with open(prj, "w") as fh:
fh.write(correct.toWkt(QgsCoordinateReferenceSystem.WKT1_ESRI))
Breakdown: native:assignprojection produces a layer with the new label and identical coordinates — the Processing equivalent of setCrs, usable in models and batch runs. Saving it to GeoPackage writes the CRS into the file's metadata tables. For shapefiles, rewriting the .prj with the ESRI flavour of WKT is the quick in-place fix; keep a backup of the original. Never fix a wrong label with native:reprojectlayer.
Fix many files at once
Mislabelling tends to affect whole deliveries — every file exported by the same misconfigured tool. Once one file is diagnosed, the fix can be applied in bulk with a check that the result lands where expected.
from pathlib import Path
from qgis.core import QgsVectorLayer, QgsRectangle
region = QgsRectangle(5.8, 47.2, 15.1, 55.1) # expected area in lon/lat
to_wgs = QgsCoordinateTransform(correct, wgs84, QgsProject.instance())
review = []
for shp in sorted(Path("/data/inbox").glob("*.shp")):
lyr = QgsVectorLayer(str(shp), shp.stem, "ogr")
lyr.setCrs(correct)
box = to_wgs.transformBoundingBox(lyr.extent())
if region.contains(box):
processing.run("native:savefeatures", {
"INPUT": lyr, "OUTPUT": f"/data/fixed/{shp.stem}.gpkg", "LAYER_NAME": shp.stem})
else:
review.append(shp.name)
print(len(review), "files need manual review:", review[:5])
Breakdown: Relabelling every file blindly would mislabel any file that was actually correct. Testing each relabelled extent against the expected region — the data's country or project area in longitude and latitude — catches files that do not fit the diagnosis. Those go on a review list rather than being changed. The corrected copies are written as GeoPackages with the right CRS embedded, leaving the originals untouched for comparison.
Stop it happening again
Most mislabelling comes from defaults: QGIS's "CRS for new layers" setting, a CSV import that assumes the project CRS, an export tool that writes WGS 84 regardless. Two settings and one habit prevent most of it.
from qgis.core import QgsSettings
s = QgsSettings()
print("new layer CRS behaviour:", s.value("app/projections/unknownCrsBehavior"))
s.setValue("app/projections/unknownCrsBehavior", "PromptUserForCrs")
Breakdown: Setting the unknown-CRS behaviour to prompt makes QGIS ask instead of silently assigning a default when a layer has no CRS, which is where many wrong labels originate. For delimited text, always pass crs= explicitly in the URI, as shown in loading a CSV as a point layer. And make the coordinate-range check from the first section part of every import script: it costs milliseconds and catches the error at the point where it is cheapest to fix.
QGIS version compatibility
setCrs, native:assignprojection and QgsCoordinateTransform behave the same on QGIS 3.34 LTR, 3.40 LTR and QGIS 4. The WKT flavour enum is QgsCoordinateReferenceSystem.WktVariant.WKT1_ESRI on QGIS 4. The settings key for unknown-CRS behaviour has been stable through QGIS 3, though its accepted values are best checked in the Options dialog of your version.
Troubleshooting
- The layer moved further away after "fixing". It was reprojected instead of relabelled; undo and use
setCrsornative:assignprojection. - Close but tens of metres off. Right projection, wrong datum; test the datum variants of the candidate.
- The fix disappears when reopening the file. Only the project was changed; write the label into the file.
- Every candidate fails. The coordinates may be in a local engineering system or swapped axes; check the axis order and ask the data provider.
Conclusion
Treat a CRS as a label: recognise mislabelled layers from coordinate ranges, test candidate CRSs against a known location, relabel with setCrs or native:assignprojection without touching coordinates, write the corrected label into the data, guard bulk fixes with a region check, and configure QGIS to prompt rather than assume.
Frequently Asked Questions
How do I know if a layer needs relabelling or reprojecting? If it is in the wrong place, relabel. If it is in the right place but you need it in another CRS, reproject.
Can the wrong label damage data? Only if someone reprojects or measures based on it. Relabelling itself is harmless and reversible.
What if latitude and longitude are swapped?
That is an axis-order problem, not a CRS label; swap coordinates with native:swapxy.
Does relabelling change stored area fields? No, but those fields may have been computed under the wrong assumption — recompute them.