Subsection

Click vs Typer for Geospatial Workflows

TL;DR: For greenfield geospatial tooling choose Typer — its native type hints eliminate boilerplate CRS validation; for legacy code or intricate parameter interdependencies, Click’s explicit callback chain gives finer control with no surprises.

Prerequisites

This page is part of the CLI Architecture & Design Patterns guide.

  • Python 3.10 or later (required for match statements and typing.Annotated)
  • click>=8.1 or typer>=0.12 (Typer 0.12 targets Click 8.1 internally)
  • Geospatial stack: geopandas>=0.14, rasterio>=1.3.9, pyproj>=3.6, shapely>=2.0
  • Optional but recommended: pyogrio>=0.7 as the vector I/O engine (faster than fiona for batch reads)
  • rich>=13 for progress bars and formatted output
  • uv or pip-tools for reproducible wheels — system GDAL bindings must match the Python package versions exactly

Install the minimal set:

pip install "typer[all]>=0.12" geopandas pyogrio pyproj rasterio shapely

Problem Framing

A geospatial batch pipeline fails in ways that generic Python CLIs do not: a CRS mismatch between input files silently produces geometrically correct but geographically wrong output; a missing GDAL driver raises an OSError deep inside a ten-second raster read rather than at startup; an invalid EPSG code is accepted by the argument parser and only rejected when pyproj.CRS.from_epsg() fires during the first geometry transform. These failures share a root cause — validation happens too late, after expensive I/O has already begun. The framework decision shapes where validation lives and how clearly it reports failures.


Click vs Typer decision flow for geospatial CLIs A flowchart showing the decision path from "New geospatial CLI?" through three questions — greenfield vs legacy, complex parameter interdependencies, and Rich output priority — arriving at either Typer or Click. New geospatial CLI? Greenfield codebase? Yes No Complex param interdeps? Rich output priority? Yes Click explicit callbacks No Yes No Click legacy compat Typer type-driven, Rich

Architectural Comparison

The foundational difference is where parameter resolution and type coercion live. Click uses explicit decorator stacks (@click.option, @click.argument, custom ParamType subclasses) so the parsing lifecycle is transparent but verbose. Typer reads Python type annotations at import time and generates the full Click command tree internally — you write a plain function signature and get validation, coercion, and Rich-formatted help for free.

Dimension Click Typer
Validation engine Manual ParamType.convert() callbacks Inline Python validators passed to typer.Option(callback=...)
Type coercion Explicit type= on each decorator Native Python annotations (Path, int, Enum)
Help formatting Plain text, highly customisable Rich auto-formatted, colour-coded
Subcommand groups @click.group() + @group.command() typer.Typer() + app.add_typer()
Context injection click.Context passed explicitly Via typer.Context or function defaults
Migration path Native — Click is stable Incremental: typer.main.get_command(app) exposes a Click group
Learning curve Steeper; explicit every step Shallower; Pythonic defaults

For teams comparing options, the Argument Parsing with Typer page covers type-driven annotation patterns in depth, while Click’s strengths become clearer in the CLI Subcommand Organization discussion.

Step-by-Step Implementation

The following five steps build a production batch coordinate-transformer CLI. Each step includes the Typer form alongside the equivalent Click pattern so you can evaluate them side by side.

Step 1 — Guard Geospatial Imports at Module Root

Place dependency guards at the top of your CLI module. If GDAL or PROJ is absent the command should refuse to register rather than failing mid-batch.

# geo_cli/__init__.py
import sys

try:
    import rasterio  # noqa: F401
    import pyogrio   # noqa: F401
    import pyproj    # noqa: F401
except ImportError as exc:
    sys.exit(
        f"Missing geospatial dependency: {exc}\n"
        "Install with: pip install rasterio pyogrio pyproj"
    )

See Handling missing dependencies gracefully in Click apps for an extended pattern that intercepts OSError raised during GDAL driver initialisation and wraps it in a structured diagnostic message.

Step 2 — Define Command Groups by Data Type

Structure subcommands around GIS taxonomy (vector, raster, metadata). In Typer each group is a separate Typer instance; in Click it is a @click.group() with nested @group.command() decorators.

# geo_cli/app.py
import typer
from pathlib import Path

app = typer.Typer(
    name="geocli",
    help="Geospatial batch transformer — reproject, clip, and validate spatial data.",
    no_args_is_help=True,
)
vector_app = typer.Typer(no_args_is_help=True)
raster_app = typer.Typer(no_args_is_help=True)

app.add_typer(vector_app, name="vector", help="Vector file operations (GeoJSON, GPKG, Shapefile).")
app.add_typer(raster_app, name="raster", help="Raster file operations (GeoTIFF, COG, NetCDF).")

For a deep dive into structuring complex command hierarchies, consult CLI Subcommand Organization, which covers shared context, command aliases, and lazy-load patterns.

Step 3 — Validate CRS Inputs at the CLI Boundary

Never let an invalid EPSG code reach geopandas.GeoDataFrame.to_crs(). Validate against the live PROJ registry in a Typer callback so the error is raised before any file I/O begins.

# geo_cli/validators.py
import typer
import pyproj

def validate_epsg(value: int) -> int:
    """Reject codes not registered in the local PROJ database."""
    try:
        pyproj.CRS.from_epsg(value)
    except pyproj.exceptions.CRSError as exc:
        raise typer.BadParameter(
            f"EPSG:{value} is not valid in the current PROJ database: {exc}"
        )
    return value

def validate_bbox(value: str) -> tuple[float, float, float, float]:
    """Parse and validate 'minx,miny,maxx,maxy' bounding box string."""
    try:
        parts = [float(p) for p in value.split(",")]
    except ValueError:
        raise typer.BadParameter("Bounding box must be four comma-separated floats.")
    if len(parts) != 4:
        raise typer.BadParameter("Expected exactly four values: minx,miny,maxx,maxy.")
    minx, miny, maxx, maxy = parts
    if minx >= maxx or miny >= maxy:
        raise typer.BadParameter("minx must be less than maxx, and miny less than maxy.")
    return (minx, miny, maxx, maxy)

The equivalent Click pattern uses a click.ParamType subclass with a convert() method — more boilerplate but identical semantics:

# Click equivalent
import click

class EPSGType(click.ParamType):
    name = "epsg"

    def convert(self, value, param, ctx):
        try:
            code = int(value)
            pyproj.CRS.from_epsg(code)
            return code
        except (ValueError, pyproj.exceptions.CRSError) as exc:
            self.fail(f"EPSG:{value} is invalid: {exc}", param, ctx)

EPSG = EPSGType()

Step 4 — Build the Batch Reprojection Command

Wire the validators into a complete command with progress tracking. pyogrio is preferred over fiona for vector I/O because its vectorised read path is 3–8× faster on large GeoPackages.

# geo_cli/commands/vector.py
from __future__ import annotations

import sys
from pathlib import Path
from typing import Annotated

import typer
import geopandas as gpd
from rich.progress import track

from geo_cli.app import vector_app
from geo_cli.validators import validate_epsg

@vector_app.command("reproject")
def reproject_vectors(
    input_dir: Annotated[Path, typer.Argument(
        help="Directory containing GeoJSON or GPKG files.",
        exists=True,
        file_okay=False,
        dir_okay=True,
        readable=True,
    )],
    target_epsg: Annotated[int, typer.Option(
        "--epsg",
        help="Target EPSG code, validated against the PROJ registry.",
        callback=validate_epsg,
    )] = 4326,
    overwrite: Annotated[bool, typer.Option(
        "--overwrite/--no-overwrite",
        help="Overwrite existing output files.",
    )] = False,
) -> None:
    """Reproject all GeoJSON and GPKG files in a directory to a target CRS."""
    # Defer heavy import until command body — keeps help text fast
    patterns = ("*.gpkg", "*.geojson", "*.shp")
    files: list[Path] = []
    for pat in patterns:
        files.extend(input_dir.glob(pat))

    if not files:
        typer.echo(f"No vector files found in {input_dir}", err=True)
        raise typer.Exit(code=2)

    failed: list[str] = []

    for path in track(files, description="Reprojecting…"):
        out_path = path.parent / f"{path.stem}_epsg{target_epsg}.gpkg"

        if out_path.exists() and not overwrite:
            typer.echo(f"Skipping {path.name} — output exists (use --overwrite).", err=True)
            continue

        try:
            # pyogrio engine avoids fiona's per-feature overhead
            gdf: gpd.GeoDataFrame = gpd.read_file(path, engine="pyogrio")

            if gdf.crs is None:
                raise ValueError(f"No CRS defined in {path.name}; set one before reprojecting.")

            gdf = gdf.to_crs(epsg=target_epsg)
            gdf.to_file(out_path, driver="GPKG", engine="pyogrio")

        except Exception as exc:  # noqa: BLE001
            typer.echo(f"ERROR {path.name}: {exc}", err=True)
            failed.append(path.name)

    if failed:
        typer.echo(f"\n{len(failed)} file(s) failed: {', '.join(failed)}", err=True)
        raise typer.Exit(code=1)

    raise typer.Exit(code=0)

Step 5 — Wire in Layered Configuration

Hard-coding --epsg 4326 in every invocation is fragile. Apply the config cascade pattern documented in Configuration File Management: defaults in code → YAML file → environment variable → CLI flag. The environment-variable layer is covered in Environment Variable Sync.

# geo_cli/config.py
from __future__ import annotations

import os
from pathlib import Path

import yaml

_CONFIG_FILE = Path.home() / ".config" / "geocli" / "config.yaml"

_DEFAULTS: dict[str, int | str | bool] = {
    "default_epsg": 4326,
    "workers": 4,
    "overwrite": False,
}

def load_config() -> dict[str, int | str | bool]:
    cfg = dict(_DEFAULTS)
    if _CONFIG_FILE.exists():
        with _CONFIG_FILE.open() as fh:
            file_cfg: dict = yaml.safe_load(fh) or {}
        cfg.update(file_cfg)
    # Environment variables override the YAML file
    if epsg := os.environ.get("GEOCLI_DEFAULT_EPSG"):
        cfg["default_epsg"] = int(epsg)
    if workers := os.environ.get("GEOCLI_WORKERS"):
        cfg["workers"] = int(workers)
    return cfg

Then in the command definition replace the hardcoded default:

from geo_cli.config import load_config

_cfg = load_config()

@vector_app.command("reproject")
def reproject_vectors(
    input_dir: Annotated[Path, typer.Argument(...)],
    target_epsg: Annotated[int, typer.Option(
        "--epsg",
        callback=validate_epsg,
        envvar="GEOCLI_DEFAULT_EPSG",  # Typer also reads this automatically
    )] = _cfg["default_epsg"],  # type: ignore[assignment]
    ...
) -> None:
    ...

For YAML-based configuration management patterns including schema validation with pydantic, see Managing YAML configs for geospatial CLI workflows.

Error Handling and Gotchas

CRS mismatch in mixed-projection directories. When input_dir contains files in different CRS (e.g., EPSG:4326 mixed with EPSG:32632), gdf.to_crs() succeeds silently for each file individually but the outputs may not overlay correctly if you assumed they were already aligned. Always log the source CRS alongside the target:

import logging

logger = logging.getLogger("geocli.reproject")

# Inside the processing loop:
src_crs = gdf.crs.to_epsg()
logger.info("Reprojecting %s from EPSG:%s to EPSG:%s", path.name, src_crs, target_epsg)

GDAL driver not available. On minimal Docker images, rasterio.open() raises rasterio.errors.RasterioIOError: not recognized as a supported file format when the GDAL build lacks the relevant driver. Test driver availability at CLI startup rather than mid-batch:

import rasterio.drivers

def assert_drivers(*names: str) -> None:
    available = rasterio.drivers.raster_driver_extensions()
    missing = [n for n in names if n.lower() not in available]
    if missing:
        raise RuntimeError(f"GDAL drivers not available: {missing}. Rebuild with --enable-driver flags.")

Typer version mismatch with Click. Typer 0.12 requires Click 8.1. If your project pins click<8, installing Typer will either refuse or silently downgrade. Pin both explicitly in your pyproject.toml and validate with pip check in CI.

Missing PROJ_DATA on packaged binaries. When distributing with shiv or PyInstaller, pyproj.CRS.from_epsg() fails with proj.db: no such file or directory. Set PROJ_DATA to the path of the bundled PROJ database in your entrypoint wrapper.

Lazy import overhead. Importing geopandas at module scope adds 300–800 ms of startup overhead — detectable when geocli --help is noticeably slow. Move all GIS imports inside command function bodies.

Verification

After installing the CLI, verify the end-to-end pipeline with a minimal round-trip:

# Create a tiny test GeoPackage
python - <<'EOF'
import geopandas as gpd
from shapely.geometry import Point

gdf = gpd.GeoDataFrame(
    {"name": ["test_point"]},
    geometry=[Point(13.405, 52.52)],  # Berlin, WGS84
    crs="EPSG:4326"
)
gdf.to_file("/tmp/test_input.gpkg", driver="GPKG")
EOF

# Run the reproject command
geocli vector reproject /tmp --epsg 32633
echo "Exit code: $?"

# Verify output CRS
python - <<'EOF'
import geopandas as gpd
gdf = gpd.read_file("/tmp/test_input_epsg32633.gpkg")
assert gdf.crs.to_epsg() == 32633, f"Expected EPSG:32633, got {gdf.crs}"
print("CRS verified:", gdf.crs)
EOF

Expected: exit code 0 and CRS verified: EPSG:32633. A non-zero exit code combined with the rich.progress bar stopping mid-file indicates a corrupted geometry — run geopandas.read_file(path).is_valid.all() on the failing input.

Performance Notes

Batch throughput. For directories exceeding 500 files, the single-process loop becomes the bottleneck. Switch to concurrent.futures.ProcessPoolExecutor with worker count capped at min(os.cpu_count(), 8) to avoid GDAL’s internal thread-pool contention. Each worker must instantiate its own geopandas context — shared objects are not fork-safe.

Memory footprint. gpd.read_file() loads the entire layer into RAM. For files larger than ~500 MB, read in chunks with pyogrio.read_dataframe(path, max_features=50_000, skip_features=offset) and process each chunk independently.

I/O bottleneck on network storage. When input_dir is an NFS mount or S3-backed filesystem (via s3fs), GPKG reads are limited by round-trip latency. Pre-stage files to a local temporary directory and process from there. The Async I/O for raster processing pattern applies directly to this scenario.

FAQ

Can I mix Click and Typer commands in the same project?

Yes. Typer wraps Click internally, so typer.main.get_command(app) returns a click.Group. You can attach legacy Click commands to that group directly: typer_group.add_command(old_click_cmd). This enables incremental migration — rewrite commands one at a time without breaking the existing CLI surface.

How do I validate a bounding box argument in Typer?

Define a callback that splits the string on commas, coerces each part to float, and checks that minx < maxx and miny < maxy. Pass it to typer.Option(callback=validate_bbox). The validate_bbox function in Step 3 above is a complete implementation — raise typer.BadParameter with the reason string and Typer formats the error automatically.

Why does importing geopandas slow my CLI startup?

geopandas loads pyogrio (or fiona), GDAL, and PROJ on import — a chain that costs 300–800 ms on warm disk, more on cold starts inside containers. Move import geopandas as gpd inside the command function body so that geocli --help remains instant even when the full GIS stack is not installed.

Which framework is easier to test with pytest?

Typer ships typer.testing.CliRunner (a thin wrapper around Click’s test runner). Invoke commands without spawning a subprocess: result = runner.invoke(app, ["vector", "reproject", str(tmp_gpkg_dir)]). Assert result.exit_code == 0 and inspect the output path’s CRS. Click’s CliRunner works identically if you expose the Click group via typer.main.get_command(app).

How do I add shell auto-completion for EPSG codes?

For Typer, implement a completion callback that queries pyproj.get_codes("EPSG", pyproj.enums.PJType.PROJECTED_CRS) and returns matching strings. Wire it to typer.Option(autocompletion=epsg_completer). Run geocli --install-completion to register the generated completion script. The Adding auto-completion to Python spatial CLI tools page provides a full implementation including fuzzy EPSG name matching.