TL;DR Answer
pyproj.exceptions.CRSError: Input is not a CRS means the value you handed to pyproj could not be parsed into a coordinate reference system. Nine times out of ten the culprit is a bare EPSG number — 4326 or the string "4326" — instead of an authority-qualified identifier. Pass an integer through CRS.from_epsg(4326), or hand GeoPandas the string "EPSG:4326", and the error disappears. Getting this right is the entry point to everything else in CRS Alignment and Projection Handling.
from pyproj import CRS
# WRONG — a bare number pyproj cannot resolve
# CRS("4326") -> CRSError: Input is not a CRS: 4326
# RIGHT — three equivalent, valid forms
crs = CRS.from_epsg(4326) # integer via the EPSG factory
crs = CRS.from_user_input("EPSG:4326")
crs = CRS("EPSG:32633") # authority-qualified string
# Reprojecting a GeoDataFrame the safe way
gdf = gdf.to_crs("EPSG:32633") # transform geometry to UTM 33N
# Declaring (not transforming) a CRS on data that has none
gdf = gdf.set_crs("EPSG:4326") # labels coordinates as lon/latPart of: CRS Alignment and Projection Handling
What the Error Really Tells You
pyproj sits underneath GeoPandas, rasterio, and rioxarray, so CRSError: Input is not a CRS surfaces from many call sites but always means the same thing: the object handed to the pyproj.CRS constructor was not something PROJ knows how to interpret. The constructor is deliberately strict. It accepts authority strings ("EPSG:4326"), WKT2, PROJ4 text, CF-style dictionaries, and other CRS objects — but it refuses to guess when you give it a bare number, a truncated WKT block, or None.
The error text echoes the offending value, which is your fastest diagnostic:
pyproj.exceptions.CRSError: Input is not a CRS: 4326
pyproj.exceptions.CRSError: Input is not a CRS: +proj=utm zone=33
pyproj.exceptions.CRSError: Input is not a CRS: NoneRead the value after the colon. 4326 tells you an EPSG code lost its EPSG: prefix somewhere upstream. A half-formed +proj= string tells you a PROJ4 definition was concatenated or truncated. None tells you the CRS was never set on the source data — a different root cause that we handle separately below. Once you classify the value, the fix is mechanical.
The Five Causes, Ranked by Frequency
Nearly every occurrence of this error reduces to one of five inputs. Ranking them by how often they appear in production geospatial pipelines:
- A bare EPSG integer or bare numeric string.
CRS(4326)works because pyproj special-cases plain integers, butCRS("4326")and manually built strings likef"{epsg_code}"fail. The fix isCRS.from_epsg(4326)or"EPSG:4326". - A missing or
Nonesource CRS. The data was loaded from a shapefile, CSV, or array that never carried CRS metadata, sogdf.crsisNone.to_crsthen has no source to transform from. - A malformed WKT or PROJ4 string. A WKT block truncated by a database column width limit, or a PROJ4 string with a missing
=or stray whitespace, parses to garbage. - Passing a
CRSobject where a plain string is expected — or the reverse. Some third-party APIs concatenate their argument into a string template; feeding them aCRSobject stringifies it into an unparseable blob. - An out-of-range or non-existent authority code.
"EPSG:999999"is well-formed syntactically but resolves to no definition in the PROJ database.
The diagram below traces how each of these inputs flows through the pyproj.CRS constructor and where it is rejected.
Fix 1: The Bare EPSG Code
This is the failure you will hit most. An EPSG code is meaningless to pyproj until it is either passed as a genuine Python integer to the EPSG factory or written with its authority prefix.
from pyproj import CRS
from pyproj.exceptions import CRSError
epsg_code = 32633 # UTM zone 33N, read from a config file as a string
# WRONG — the value arrived as a string and was passed straight through
try:
crs = CRS(str(epsg_code)) # CRS("32633")
except CRSError as exc:
print(f"Rejected: {exc}") # Input is not a CRS: 32633
# RIGHT — coerce to int for the factory, or build the authority string
crs = CRS.from_epsg(int(epsg_code))
crs = CRS.from_user_input(f"EPSG:{epsg_code}")
print(crs.to_authority()) # ('EPSG', '32633')CRS.from_user_input is the most forgiving entry point: it accepts integers, authority strings, WKT, PROJ4, and existing CRS objects, and it is exactly what GeoPandas calls internally. When in doubt, route everything through it.
Fix 2: A GeoDataFrame With No CRS
When gdf.crs is None, to_crs cannot run because it has no source projection to transform from. The distinction between set_crs and to_crs is the single most important thing to internalise here, and it is the same distinction that trips up anyone fixing projection mismatches in pandas GeoDataFrames:
set_crslabels the data. It attaches a CRS to coordinates that already carry no metadata. It does not move a single point.to_crstransforms the data. It requires a known source CRS and mathematically reprojects every coordinate to the target.
import geopandas as gpd
gdf = gpd.read_file("stations.csv") # loaded from lon/lat columns, no CRS
print(gdf.crs) # None
# WRONG — no source CRS to transform from
# gdf = gdf.to_crs("EPSG:32633") # raises: cannot transform naive geometries
# RIGHT — declare the true source first, THEN transform
gdf = gdf.set_crs("EPSG:4326") # coordinates are known to be lon/lat
gdf = gdf.to_crs("EPSG:32633") # now reproject to UTM 33NIf you call set_crs with a projection the data is not actually in, you will not get an error — you will get silently wrong geometry. Confirm the true source system from the data provider before labelling.
A Production-Ready ensure_crs() Helper
Rather than scatter from_epsg, from_user_input, and prefix-building across a codebase, funnel every CRS argument through one validated helper. It accepts an int, a str, or an existing pyproj.CRS, normalises each case, and raises a clear error instead of a cryptic one.
from __future__ import annotations
import logging
from pyproj import CRS
from pyproj.exceptions import CRSError
logger = logging.getLogger(__name__)
CRSLike = int | str | CRS
def ensure_crs(value: CRSLike) -> CRS:
"""Normalize any reasonable CRS input to a validated pyproj.CRS.
Accepts an EPSG integer, an authority/WKT/PROJ4 string, or an existing
CRS object. Raises a descriptive ValueError instead of a bare CRSError so
callers get an actionable message.
Parameters
----------
value : int | str | pyproj.CRS
The CRS specification to validate.
Returns
-------
pyproj.CRS
A validated coordinate reference system.
Raises
------
ValueError
If value is None, empty, or cannot be parsed into a CRS.
"""
if value is None:
raise ValueError("CRS is None — the source data has no projection set.")
# Already a validated CRS: return as-is.
if isinstance(value, CRS):
return value
# Integers are EPSG codes; route through the dedicated factory.
if isinstance(value, int):
try:
return CRS.from_epsg(value)
except CRSError as exc:
raise ValueError(f"{value} is not a valid EPSG code.") from exc
# Strings: reject empties, promote bare digits to an EPSG authority string.
if isinstance(value, str):
text = value.strip()
if not text:
raise ValueError("CRS string is empty.")
if text.isdigit():
text = f"EPSG:{text}"
try:
return CRS.from_user_input(text)
except CRSError as exc:
raise ValueError(f"Cannot parse CRS from {value!r}.") from exc
raise ValueError(
f"Unsupported CRS type {type(value).__name__}; "
"pass an int, str, or pyproj.CRS."
)The key move is the text.isdigit() branch: it catches the exact input — a bare "4326" — that produced CRSError: Input is not a CRS in the first place, and repairs it before pyproj ever sees it. Wire this in wherever a CRS enters your system:
def reproject(gdf, target):
"""Reproject a GeoDataFrame to a validated target CRS."""
target_crs = ensure_crs(target) # int, str, or CRS all accepted
if gdf.crs is None:
raise ValueError("Source GeoDataFrame has no CRS; call set_crs first.")
return gdf.to_crs(target_crs)
# All of these now succeed identically
reproject(gdf, 32633)
reproject(gdf, "32633")
reproject(gdf, "EPSG:32633")
reproject(gdf, CRS.from_epsg(32633))The same normalisation protects raster workflows, where the target CRS is passed to a warp routine when reprojecting a raster with rasterio warp reproject — feed ensure_crs(target).to_wkt() to the reproject call and malformed identifiers can never reach GDAL.
Walkthrough: A GeoDataFrame That Arrives Without a CRS
Here is the end-to-end path from a broken load to a clean, verified reprojection — the most common real-world scenario behind this error. A CSV of sensor coordinates arrives with no spatial metadata, and every naive to_crs call fails.
1. Load and diagnose
import geopandas as gpd
from shapely.geometry import Point
import pandas as pd
df = pd.read_csv("sensors.csv") # columns: id, lon, lat, value
gdf = gpd.GeoDataFrame(
df,
geometry=[Point(xy) for xy in zip(df.lon, df.lat)],
)
print(gdf.crs) # None <-- root cause of every downstream failure2. Declare the true source CRS
The coordinates are decimal degrees, so the honest source is EPSG:4326. Use set_crs, which labels without moving points, and validate through the helper.
gdf = gdf.set_crs(ensure_crs("EPSG:4326"))
print(gdf.crs.to_authority()) # ('EPSG', '4326')3. Reproject to a metric CRS for feature engineering
Distance-based features — buffers, nearest-neighbour statistics, spatial lags — require a projected metric system, not degrees. Transform to the local UTM zone.
gdf_utm = gdf.to_crs(ensure_crs(32633))
print(gdf_utm.crs.is_projected) # True — metres, safe for distance mathA projected metric CRS is a hard prerequisite for the rest of the Spatial Feature Engineering for Machine Learning work; computing a buffer or a distance matrix in raw degrees produces meaningless features.
Verification
A reprojection that runs without raising is not automatically correct. Confirm three properties: the CRS is what you expect, it is projected when you need metres, and a round-trip returns the original coordinates within floating-point tolerance.
import numpy as np
# 1. Identity check — is the CRS the one you intended?
assert gdf_utm.crs == ensure_crs(32633), "unexpected target CRS"
assert gdf_utm.crs.is_projected, "expected a metric (projected) CRS"
# 2. Round-trip check — reproject back to source and compare coordinates
back = gdf_utm.to_crs(ensure_crs(4326))
orig_xy = np.column_stack([gdf.geometry.x, gdf.geometry.y])
back_xy = np.column_stack([back.geometry.x, back.geometry.y])
max_drift = np.abs(orig_xy - back_xy).max()
print(f"Max round-trip drift: {max_drift:.3e} degrees")
assert max_drift < 1e-6, "round-trip drift too large — check the transform"A max drift near 1e-9 degrees confirms the forward and inverse transforms are consistent and no silent datum shift crept in. A drift of whole degrees means set_crs was used to convert rather than label, or the declared source CRS was wrong — revisit step 2 of the walkthrough. Making these three assertions part of your ingestion tests stops a mislabelled CRS from poisoning every feature computed downstream.
FAQ
What does CRSError: Input is not a CRS actually mean?
pyproj raises CRSError: Input is not a CRS when the value you passed cannot be parsed into a coordinate reference system. The most common trigger is a bare integer or an ambiguous string such as 4326 instead of the authority-qualified form EPSG:4326. It also fires on malformed WKT or PROJ4 strings, on None, and on empty strings. The fix is to pass a well-formed identifier — either an integer via CRS.from_epsg(4326) or the string "EPSG:4326" — rather than a raw number pyproj cannot resolve.
Why does to_crs fail when my GeoDataFrame has no CRS set?
to_crs reprojects from the frame’s current CRS to a target CRS, so it requires a known source. When gdf.crs is None, GeoPandas cannot compute the transform and raises an error about a missing source CRS. If you already know the coordinates are in a specific system, declare it with set_crs first, which only labels the data, then call to_crs to actually transform the geometry. Never use set_crs to convert coordinates.
Should I pass an integer, a string, or a CRS object to pyproj?
All three work if you use them correctly. An EPSG integer must go through CRS.from_epsg(code) or be passed as the authority string "EPSG:code" — a bare integer inside a string like "4326" is the classic failure. A pyproj.CRS object is the safest currency because it is already validated. A defensive helper like ensure_crs() that accepts int, str, or CRS and normalizes every case to a validated CRS object removes the ambiguity from your whole pipeline.
Related
- Fixing Projection Mismatches in pandas GeoDataFrames
- Reprojecting a Raster with rasterio warp reproject
- CRS Alignment and Projection Handling
- Spatial Feature Engineering for Machine Learning
Part of: CRS Alignment and Projection Handling — Spatial Feature Engineering for Machine Learning