To reject a bad EPSG code before any file is touched, attach a callback= to your Typer --crs option that normalises the value and resolves it through pyproj.CRS.from_epsg; on any failure raise typer.BadParameter, which prints a usage message and exits with code 2. Because a callback runs before the command body, invalid input never reaches a gdal.Open or pyogrio.read_dataframe call. It builds on the Argument Parsing with Typer guide, part of the broader CLI Architecture & Design Patterns reference.
Prerequisites
- Python 3.10 or later
pip install "typer>=0.12" "pyproj>=3.6"- pyproj ships its own bundled PROJ database, so no system GDAL is required for validation itself; the downstream I/O in your command may still need GDAL 3.4+
A CRS value is the most error-prone argument a geospatial command accepts, because a typo like EPSG:4362 is a perfectly valid string that silently designates the wrong planet-scale projection. Enforcing it at the boundary keeps the rest of your pipeline honest. For how options and callbacks fit together, and where CRS validation sits relative to config-file defaults, see Configuration File Management.
Where Validation Runs in the Call Order
The value of a callback is entirely about when it fires. Typer resolves and validates every option, running each option’s callback, before it invokes your command function. A raised typer.BadParameter short-circuits the whole call: the command body never executes, so no dataset is opened and no output file is created.
Before the code, it helps to see the shapes a CRS argument arrives in. Users type whichever form their previous tool printed, so the callback normalises first and resolves second.
Collapsing the first two branches to one integer is what makes the error message useful: by the time pyproj is called you know the user meant an authority code, so a failure can say 4362 is not a registered EPSG code rather than the generic unsupported CRS input.
Complete Working Implementation
The command below reprojects a vector file. The --crs option carries a callback that does all the validation; the command body assumes it received a canonical, resolvable EPSG string. Copy it, run python crs_cli.py reproject input.gpkg output.gpkg --crs 4326, and try a bad code to see the exit behaviour.
#!/usr/bin/env python3
"""
Validate an EPSG code at the Typer CLI boundary before any dataset I/O.
Usage: python crs_cli.py reproject input.gpkg output.gpkg --crs EPSG:32633
"""
from pathlib import Path
import typer
import pyproj
from pyproj.exceptions import CRSError
app = typer.Typer(add_completion=False)
def normalise_epsg(raw: str) -> int:
"""Reduce '4326' or 'EPSG:4326' to the integer 4326.
Accepts an optional, case-insensitive 'EPSG:' authority prefix and
nothing else. Any other authority (ESRI:, IAU:) is rejected here so
the rest of the pipeline only ever deals with EPSG integer codes.
"""
token = raw.strip()
if ":" in token:
authority, _, code = token.partition(":")
if authority.upper() != "EPSG":
raise ValueError(f"unsupported authority {authority!r}; expected EPSG")
token = code
return int(token) # raises ValueError on non-numeric input
def validate_crs(value: str) -> str:
"""Typer callback: turn any bad EPSG code into an exit-code-2 usage error.
Runs BEFORE the command body, so an invalid code never reaches file I/O.
Returns the canonical 'EPSG:<code>' string on success.
"""
try:
code = normalise_epsg(value)
except ValueError as exc:
# Non-numeric, or a non-EPSG authority prefix.
raise typer.BadParameter(f"{value!r} is not an EPSG code ({exc})")
try:
# from_epsg rejects syntactically valid but unregistered codes.
crs = pyproj.CRS.from_epsg(code)
except CRSError:
raise typer.BadParameter(
f"EPSG:{code} is not a known coordinate reference system"
)
# Return the normalised, canonical form for the command body to consume.
return f"EPSG:{crs.to_epsg()}"
@app.command()
def reproject(
source: Path = typer.Argument(..., exists=True, dir_okay=False,
help="Input vector dataset"),
destination: Path = typer.Argument(..., dir_okay=False,
help="Output vector dataset"),
crs: str = typer.Option(
"EPSG:4326",
"--crs",
callback=validate_crs, # validation happens here, pre-command
help="Target CRS as an EPSG code (e.g. 4326 or EPSG:4326)",
),
) -> None:
"""Reproject SOURCE to the target CRS and write DESTINATION."""
# By the time we reach this line, `crs` is guaranteed resolvable.
import pyogrio # imported lazily so --help stays fast
typer.echo(f"Reprojecting {source} to {crs}")
gdf = pyogrio.read_dataframe(source)
gdf = gdf.to_crs(crs)
pyogrio.write_dataframe(gdf, destination)
typer.echo(f"Wrote {destination}")
if __name__ == "__main__":
app()
Step Annotations
-
normalise_epsgsplits authority from code —str.partition(":")cleanly handles both4326(no colon, returned as-is) andEPSG:4326. Rejecting any non-EPSGauthority here keeps the contract narrow: the command body only ever sees an EPSG integer, so downstream reprojection logic never has to branch on authority. -
int(token)is where the string-vs-int trap is defused — Typer hands the callback the raw command-line string, so4326arrives as"4326". The explicitint()both converts it and rejects garbage like"EPSG:abc"viaValueError, which the surroundingtryturns into a clean usage error. -
pyproj.CRS.from_epsg(code)is the real gate — Passing an integer tofrom_epsglooks the code up in the bundled PROJ/EPSG database. A number that is well-formed but unregistered, such as9999, raisesCRSErrorhere rather than surfacing as a confusing failure deep insideto_crs. -
raise typer.BadParameter(...)— This is the bridge between pyproj’s exception vocabulary and Typer’s CLI contract. Typer catches it, printsInvalid value for '--crs': ...to stderr, and exits with code2— the POSIX usage-error code — without a Python traceback. -
callback=validate_crson the option — Wiring the function ascallback=is what guarantees it runs beforereproject’s body. The command receives the callback’s return value, socrsinside the body is the canonicalEPSG:4326string, already validated. -
Lazy
import pyogrio— Deferring the heavy vector-I/O import until after validation keeps--helpand error paths fast, and ensures a bad--crsnever even loads the GDAL-backed reader.
Named Gotcha: A Well-Formed Code That Does Not Exist
The failure that slips past naive validation is the syntactically valid but non-existent code, most famously EPSG:9999. A regex check like ^(EPSG:)?\d+$ accepts it happily, and even int("9999") succeeds — so a hand-rolled validator that only checks “is it a number?” waves it through. The bad value then travels all the way into gdf.to_crs("EPSG:9999"), where pyproj finally raises CRSError after the input file has already been read into memory.
The fix is to make pyproj.CRS.from_epsg itself the authority, as in the implementation above. It performs a real database lookup, so from_epsg(9999) raises CRSError and the callback converts it to exit code 2 before a single byte of vector data is read:
>>> import pyproj
>>> pyproj.CRS.from_epsg(4326) # OK
<Geographic 2D CRS: EPSG:4326>
>>> pyproj.CRS.from_epsg(9999) # CRSError: crs not found
Traceback (most recent call last):
pyproj.exceptions.CRSError: Invalid projection: EPSG:9999
If you accept arbitrary CRS strings elsewhere (WKT, PROJ pipelines), swap CRS.from_epsg for pyproj.CRS.from_user_input and read the resulting crs.to_epsg() — but for an option documented as an EPSG code, from_epsg gives the tightest, clearest rejection.
The reason this check earns its place is that the codes people mistype are themselves real codes. The pairs below all differ by a single transposed digit, and each resolves to a projection that will happily reproject your data to somewhere it does not belong.
Only the middle row raises anything by itself. The other two produce a clean run and a wrong file, which is exactly the class of bug that a boundary check exists to catch.
Verification
Confirm both the accept and reject paths, and check the exit code the shell sees:
# Valid: bare integer form is accepted and normalised
python crs_cli.py reproject in.gpkg out.gpkg --crs 4326
echo "exit: $?" # -> exit: 0
# Valid: prefixed form resolves to the same canonical value
python crs_cli.py reproject in.gpkg out.gpkg --crs EPSG:32633
echo "exit: $?" # -> exit: 0
# Invalid: well-formed but unregistered code is rejected before I/O
python crs_cli.py reproject in.gpkg out.gpkg --crs EPSG:9999
echo "exit: $?" # -> Invalid value for '--crs': ... ; exit: 2
# Invalid: non-numeric input
python crs_cli.py reproject in.gpkg out.gpkg --crs WGS84
echo "exit: $?" # -> Invalid value for '--crs': ... ; exit: 2
An exit code of 2 on the last two calls — with out.gpkg never created — proves the callback stops bad input at the boundary. You can assert this directly in tests with Typer’s CliRunner:
from typer.testing import CliRunner
from crs_cli import app
runner = CliRunner()
def test_unknown_epsg_exits_2(tmp_path):
src = tmp_path / "in.gpkg"
src.write_bytes(b"") # existence check only; never opened
result = runner.invoke(app, ["reproject", str(src),
str(tmp_path / "out.gpkg"), "--crs", "EPSG:9999"])
assert result.exit_code == 2
assert not (tmp_path / "out.gpkg").exists()
FAQ
Why does my callback receive a string when I typed a number?
Typer passes the raw command-line token to a str-typed option, so 4326 arrives as the string "4326", not the integer 4326. Strip any EPSG: prefix and call int() yourself before handing the value to pyproj, and raise typer.BadParameter if the int() conversion fails.
Does pyproj reject a code like EPSG:9999?
Yes. EPSG:9999 is syntactically valid but is not registered in the EPSG database, so pyproj.CRS.from_epsg(9999) raises pyproj.exceptions.CRSError. Catching that error and re-raising typer.BadParameter is what turns an unknown authority code into a clean exit code 2 instead of a stack trace later.
What exit code does typer.BadParameter produce?
typer.BadParameter causes Typer to print a usage error to stderr and exit with code 2, matching the POSIX convention for command-line usage errors. This lets shell scripts and CI jobs distinguish bad arguments from runtime failures, which exit with code 1.
Should I validate the CRS in the callback or inside the command?
Validate in the callback. A callback runs before the command body, so a bad EPSG code fails before any raster or vector file is opened. Validating inside the command wastes the cost of opening a dataset and risks partial writes before the CRS is checked.
Related
- Argument Parsing with Typer — parent guide covering options, arguments, and callback-based validation at the CLI boundary
- How to Build a Typer CLI for Shapefile Conversion — a full Typer command that consumes a validated CRS to convert and reproject vector data