Assigning a CRS to a Raster That Has None
If the transform is correct and only the CRS label is missing, write the label in place — no resampling:
import rasterio
from rasterio.crs import CRS
with rasterio.open("scene_no_crs.tif", "r+") as dst:
assert dst.crs is None, "file already has a CRS — this is a different problem"
dst.crs = CRS.from_epsg(32636) # declare, do not reproject
The whole difficulty is knowing which code to write, not writing it. This page belongs to mastering CRS transformations in rasterio in Core Raster Fundamentals & STAC Mapping.
Assign, Reproject, or Georeference
Reprojecting a file whose CRS was merely missing is the expensive mistake here. It resamples every pixel onto a new grid based on a guess, introducing interpolation error, and still leaves the original question — what was the data in? — unanswered.
Environment & Setup
| Package | Version pin | Used for |
|---|---|---|
rasterio |
>=1.3.0 |
Reading the transform and writing the CRS in update mode |
pyproj |
>=3.6 |
Checking whether coordinates are plausible in a candidate CRS |
geopandas |
>=0.14 |
Loading a reference layer for the overlay check |
pip install "rasterio>=1.3.0" "pyproj>=3.6" "geopandas>=0.14"
Complete Working Example
from pathlib import Path
import rasterio
from pyproj import CRS as ProjCRS
from rasterio.crs import CRS
def evidence(path: str) -> dict:
"""Clues about what CRS a CRS-less raster is really in."""
p = Path(path)
clues: dict = {}
with rasterio.open(path) as src:
clues["crs"] = src.crs
clues["origin"] = (src.transform.c, src.transform.f)
clues["pixel_size"] = (src.transform.a, src.transform.e)
clues["gcps"] = bool(src.gcps[0])
for ext in (".prj", ".aux.xml", ".tfw", ".wld"):
side = p.with_suffix(p.suffix + ext) if ext == ".aux.xml" else p.with_suffix(ext)
if side.exists():
clues[f"sidecar{ext}"] = side.read_text(errors="ignore")[:400]
x, y = clues["origin"]
if -180 <= x <= 180 and -90 <= y <= 90:
clues["guess"] = "geographic degrees (e.g. EPSG:4326)"
elif 100_000 <= abs(x) <= 900_000 and 0 <= abs(y) <= 10_000_000:
clues["guess"] = "UTM metres — zone from longitude of the site"
elif abs(x) > 1_000_000 and abs(y) > 1_000_000:
clues["guess"] = "Web Mercator or a national grid"
return clues
def assign_crs(path: str, epsg: int) -> None:
"""Declare the CRS of an existing transform, in place, without resampling."""
with rasterio.open(path, "r+") as dst:
if dst.crs is not None:
raise ValueError(f"file already declares {dst.crs}; use a mismatch fix instead")
if dst.transform.is_identity:
raise ValueError("no transform — this file needs georeferencing, not a CRS")
dst.crs = CRS.from_epsg(epsg)
def utm_epsg_for(lon: float, lat: float) -> int:
zone = int((lon + 180) // 6) + 1
return (32600 if lat >= 0 else 32700) + zone
The .prj sidecar is the best evidence when it exists: it holds the full CRS definition the file was exported with, and pyproj.CRS.from_wkt on its contents usually yields an EPSG code directly. The origin-magnitude heuristic is the fallback, and it narrows the answer to a family rather than a code — which is why the UTM helper needs the site’s approximate longitude from some independent source.
Verifying the Guess
A heuristic gets you to a candidate; only an overlay confirms it. Load any trusted vector layer covering the area — a road network, a coastline, administrative boundaries — reproject it into the candidate CRS and plot it over the raster. A correct assignment makes features coincide to within a pixel or two. The most common wrong answer, a neighbouring UTM zone, is off by hundreds of kilometres and obvious; a wrong datum is off by tens to hundreds of metres and needs a closer look.
Datum confusion is the subtle case. A file in “UTM zone 36” might be on WGS84 or on a local datum such as Arc 1960, and the two differ by around 200 m in East Africa. If the overlay is close but consistently shifted in one direction, try the regional datum variants before accepting the WGS84 code. The coordinate operations behind that comparison are covered in transforming point coordinates with pyproj.
Preventing It Next Time
CRS-less files almost always come from one of three places: a format conversion that dropped the metadata, a tool that writes .tfw world files but not .prj, or a numerical pipeline that built a GeoTIFF from an array and a transform and forgot the CRS. The third is entirely within your control.
Every write in a pipeline should take its profile from a source dataset — profile = src.profile | {...} — rather than constructing one, because the source profile carries the CRS automatically. Where construction is unavoidable, make the CRS a required argument of the writing function, so omitting it is a TypeError rather than a silently unlabelled file. The audit tooling in auditing CRS and nodata drift across a collection will find any that slipped through.
Verification
import rasterio
with rasterio.open("scene_no_crs.tif") as src:
assert src.crs is not None and src.crs.to_epsg() == 32636
assert not src.transform.is_identity
print(src.crs, src.bounds)
After assigning, confirm the bounds are plausible for the site — eastings within the zone’s range, northings consistent with the latitude — and repeat the overlay once more against a second reference layer. Two independent references agreeing is strong evidence; one can coincidentally line up along a single axis.
Common Errors
rasterio.errors.RasterioIOError opening in r+ mode
The file format or storage does not support in-place updates, for example a remote object. Copy it locally, assign, and upload the result.
The raster moves after assignment
It was reprojected rather than assigned. Use update mode and set crs only; never pass it through reproject for this purpose.
The overlay is close but shifted consistently
Wrong datum. Try the regional datum variants of the same projection.
A .aux.xml sidecar overrides the new CRS
GDAL reads the sidecar on open. Delete or regenerate stale sidecars after fixing the file.
Frequently Asked Questions
Q: Is assigning a CRS the same as reprojecting? No. Assigning a CRS declares what coordinate system the existing transform is already in, and changes no pixel. Reprojecting moves pixels onto a different grid. Assigning the wrong CRS is a labelling error; reprojecting when you should have assigned creates a real geometric error.
Q: How can I tell which CRS a file should have? Look at the transform’s origin. Values between minus 180 and 180 suggest geographic degrees; eastings of a few hundred thousand with northings in millions suggest UTM; values in the millions on both axes suggest Web Mercator or a national grid. Sidecar files and provider documentation then pin down the exact code.
Q: What if the transform is missing too? Then the file is not georeferenced at all and a CRS alone cannot help. You need the corner coordinates or ground control points from another source, and the operation becomes georeferencing rather than CRS assignment.
Q: Can rioxarray do the same thing?
Yes — da.rio.write_crs(epsg) sets the CRS on a DataArray without touching values, and writing it back out produces a labelled file. It is the same operation expressed on an array rather than on a file in place.
Related
- Mastering CRS Transformations in rasterio — the parent topic.
- Fixing EPSG Mismatches in rasterio.open — when a CRS is present but wrong.
- Reprojecting a Raster from UTM to WGS84 with rasterio — when a genuine change of grid is wanted.
- Handling Antimeridian and Polar CRS Edge Cases — where the usual heuristics break down.