Article

Validating EPSG Codes as Typer CLI Options

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. This page is part of the Argument Parsing with Typer guide within 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.

EPSG validation runs before dataset I/O A left-to-right flow: the CLI parses the raw --crs token, the callback normalises and resolves it with pyproj, then either the command body opens datasets on success or the process exits with code 2 on failure before any I/O. Raw --crs token "4326" or "EPSG:4326" callback runs here normalise to int CRS.from_epsg() before any I/O valid invalid Command body opens raster / vector BadParameter exit code 2, no I/O

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

  1. normalise_epsg splits authority from codestr.partition(":") cleanly handles both 4326 (no colon, returned as-is) and EPSG:4326. Rejecting any non-EPSG authority here keeps the contract narrow: the command body only ever sees an EPSG integer, so downstream reprojection logic never has to branch on authority.

  2. int(token) is where the string-vs-int trap is defused — Typer hands the callback the raw command-line string, so 4326 arrives as "4326". The explicit int() both converts it and rejects garbage like "EPSG:abc" via ValueError, which the surrounding try turns into a clean usage error.

  3. pyproj.CRS.from_epsg(code) is the real gate — Passing an integer to from_epsg looks the code up in the bundled PROJ/EPSG database. A number that is well-formed but unregistered, such as 9999, raises CRSError here rather than surfacing as a confusing failure deep inside to_crs.

  4. raise typer.BadParameter(...) — This is the bridge between pyproj’s exception vocabulary and Typer’s CLI contract. Typer catches it, prints Invalid value for '--crs': ... to stderr, and exits with code 2 — the POSIX usage-error code — without a Python traceback.

  5. callback=validate_crs on the option — Wiring the function as callback= is what guarantees it runs before reproject’s body. The command receives the callback’s return value, so crs inside the body is the canonical EPSG:4326 string, already validated.

  6. Lazy import pyogrio — Deferring the heavy vector-I/O import until after validation keeps --help and error paths fast, and ensures a bad --crs never 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.

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.