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

Three different operations people call "fixing the CRS" If the file has a sensible transform and no CRS, assign the CRS. If it has a correct CRS and you want a different one, reproject. If it has no usable transform at all, it must be georeferenced from ground control points. Confusing the first two either leaves data mislabelled or introduces a real geometric error. What is actually wrong with the file? assign transform: correct CRS: missing write the label in place no pixel moves this page reproject transform: correct CRS: correct, but unwanted compute a new grid every pixel resampled georeference transform: missing only pixel coordinates needs control points from another source A wrong CRS label is a fourth case, covered in fixing EPSG mismatches — same fix, different diagnosis.

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

Overlay a reference and look With the correct CRS assigned, a reference road layer lines up with the roads visible in the imagery. With a neighbouring UTM zone assigned instead, the same layer appears offset by hundreds of kilometres, and with a wrong datum by tens to hundreds of metres. The overlay is the definitive test. correct CRS neighbouring zone reference sits on the imaged road reference lands hundreds of km away (not in view) Orange dashed: the reference layer. Dark: the feature visible in the imagery.

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

Origin magnitudes point at a CRS family An origin with both coordinates inside plus or minus 180 and 90 indicates geographic degrees. An easting of a few hundred thousand with a northing up to ten million indicates UTM metres. Both coordinates in the millions indicates Web Mercator or a national grid. The heuristic narrows the family; an overlay confirms the exact code. Transform origin as a first clue 34.6, 0.5 geographic degrees EPSG:4326 family 699960, 9900000 UTM metres zone from the site longitude 3850000, 55000 Web Mercator or national check the provider A clue, not a proof — confirm every guess with an overlay.
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.