How to Turn a GIS Script into a Command-Line Tool with argparse

A script you have to open before you can run it is a script only you can run. Every GIS team has one: process.py, with a block of constants at the top that must be edited, in the right way, in the right order, by someone who knows which lines are safe to touch. Wrapping it in a command-line interface takes an afternoon and changes what the script is — from a file you edit into a tool you invoke, with documented options, sensible defaults, and a --help that answers most questions before they are asked.

Problem statement

Your GIS script does the right thing but is awkward to run. The familiar symptoms:

  • Editing to run — changing the input file means opening the script and modifying a literal.
  • Nobody else can use it — a colleague has to read Python to work out which constants matter.
  • No dry run — there is no way to see what it would do without letting it write files.
  • Impossible to schedule — a cron job cannot edit constants, so each scheduled variation needs its own copy of the script.
  • Silent bad input — a typo in a CRS code surfaces fifteen minutes in, as an obscure exception from deep inside GDAL.

The goal: python clip_tool.py input.gpkg boundary.geojson --out clipped.gpkg --crs EPSG:27700 --dry-run — self-documenting, validated before any data is read, and callable from a scheduler, a Makefile, or another script.

Quick answer

Use argparse from the standard library. Declare positional arguments for the things the tool cannot work without, options with defaults for everything else, use type=Path so you get real path objects, and keep main(args) separate from the parser so the logic stays importable and testable.

The anatomy of a command line: program, positional arguments, options with values, and boolean flags.
Positionals for what the tool cannot run without; options with defaults for everything else.
#!/usr/bin/env python3
"""Clip a vector layer to a boundary."""
import argparse
import sys
from pathlib import Path
import geopandas as gpd

def build_parser():
    p = argparse.ArgumentParser(
        prog="clip_tool",
        description="Clip a vector layer to a boundary and write a GeoPackage.",
        epilog="Example: clip_tool.py sites.gpkg district.geojson --out clipped.gpkg",
    )
    p.add_argument("source", type=Path, help="layer to clip")
    p.add_argument("boundary", type=Path, help="boundary layer")
    p.add_argument("-o", "--out", type=Path, default=Path("clipped.gpkg"),
                   help="output file (default: %(default)s)")
    p.add_argument("--crs", default="EPSG:27700",
                   help="CRS to work in (default: %(default)s)")
    p.add_argument("-n", "--dry-run", action="store_true",
                   help="report what would happen, write nothing")
    p.add_argument("-v", "--verbose", action="count", default=0,
                   help="-v for info, -vv for debug")
    return p

def main(args):
    for path in (args.source, args.boundary):
        if not path.exists():
            sys.exit(f"error: file not found: {path}")

    gdf = gpd.read_file(args.source).to_crs(args.crs)
    boundary = gpd.read_file(args.boundary).to_crs(args.crs)
    clipped = gpd.clip(gdf, boundary)

    print(f"{len(gdf)}{len(clipped)} features")
    if args.dry_run:
        print(f"dry run: would write {args.out}")
        return 0
    clipped.to_file(args.out, driver="GPKG")
    print(f"wrote {args.out}")
    return 0

if __name__ == "__main__":
    sys.exit(main(build_parser().parse_args()))

Step-by-step solution

Decide what is a positional and what is an option

The distinction is not stylistic. A positional argument is something the tool has no meaningful default for and cannot run without — the input file. An option is something with a sensible default that a user may want to change — the output path, the CRS, the threshold. If you catch yourself giving a positional a default, it should be an option.

p.add_argument("source", type=Path)                      # required, no default
p.add_argument("--crs", default="EPSG:27700")            # optional, sane default

Keep positionals to two or three. Beyond that, nobody remembers the order, and named options are self-documenting at the call site.

Let argparse do the type conversion

type= runs a callable over the raw string and reports a clean error if it fails — before your code runs. type=Path gives you path objects, type=float gives you numbers, and a custom function can validate anything you like.

def crs_arg(value):
    if not value.upper().startswith("EPSG:") or not value[5:].isdigit():
        raise argparse.ArgumentTypeError(f"expected EPSG:<code>, got {value!r}")
    return value.upper()

def existing_file(value):
    path = Path(value)
    if not path.exists():
        raise argparse.ArgumentTypeError(f"file not found: {path}")
    return path

p.add_argument("source", type=existing_file)
p.add_argument("--crs", type=crs_arg, default="EPSG:27700")
p.add_argument("--min-area", type=float, default=0.0, metavar="M2")

A bad CRS now fails in milliseconds with a message that names the problem, instead of surfacing as a CRSError after the file is loaded.

Write help text as if you will forget everything

You will. Every argument gets a help= string; the parser gets a description and an epilog with a real example. %(default)s interpolates the default so the help never goes stale.

p = argparse.ArgumentParser(
    prog="clip_tool",
    description="Clip a vector layer to a boundary and write a GeoPackage.",
    epilog="Example:\n  clip_tool.py sites.gpkg district.geojson -o out.gpkg --crs EPSG:3035",
    formatter_class=argparse.RawDescriptionHelpFormatter,
)
p.add_argument("--min-area", type=float, default=0.0,
               help="drop features smaller than this, in m² (default: %(default)s)")

Add the three flags every data tool needs

--dry-run, --verbose, and --force earn their keep in every tool that writes files.

p.add_argument("-n", "--dry-run", action="store_true",
               help="show what would be written, then stop")
p.add_argument("-v", "--verbose", action="count", default=0,
               help="-v info, -vv debug")
p.add_argument("-f", "--force", action="store_true",
               help="overwrite the output if it exists")
import logging
level = [logging.WARNING, logging.INFO, logging.DEBUG][min(args.verbose, 2)]
logging.basicConfig(level=level, format="%(levelname)-7s %(message)s")

A dry run should do everything except write: read the inputs, run the analysis, report the counts. That way it verifies far more than a printed message could.

Separate parsing from doing

Keep three pieces apart: build_parser() constructs the interface, main(args) does the work, and the if __name__ == "__main__" block wires them together. Now the logic is importable from another script and testable without a subprocess.

def build_parser(): ...
def main(args) -> int: ...

if __name__ == "__main__":
    sys.exit(main(build_parser().parse_args()))
# test_clip_tool.py
def test_dry_run_writes_nothing(tmp_path):
    args = build_parser().parse_args(
        ["sites.gpkg", "district.geojson", "-o", str(tmp_path / "o.gpkg"), "--dry-run"]
    )
    assert main(args) == 0
    assert not (tmp_path / "o.gpkg").exists()

Return meaningful exit codes

A command-line tool communicates success or failure through its exit status, and schedulers act on it. 0 means success; anything else means failure. Use sys.exit("message") to print to stderr and exit with 1 in one move.

Exit code zero for success, one for a runtime failure, and two for a usage error, each read by the caller.
Exit codes are the tool's only reliable channel to a scheduler — get them right.
def main(args):
    try:
        result = do_work(args)
    except FileNotFoundError as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 1
    except Exception as exc:
        logging.exception("unexpected failure")
        return 1
    if result.empty:
        print("error: result is empty — check the CRS and the boundary", file=sys.stderr)
        return 1
    return 0

Grow into subcommands when the tool does more than one thing

When one script needs to clip and merge and report, do not add mutually exclusive flags. Add subcommands, each with its own arguments — the shape git commit and docker run use.

def build_parser():
    p = argparse.ArgumentParser(prog="gistool")
    sub = p.add_subparsers(dest="command", required=True)

    clip = sub.add_parser("clip", help="clip a layer to a boundary")
    clip.add_argument("source", type=Path)
    clip.add_argument("boundary", type=Path)
    clip.set_defaults(func=cmd_clip)

    merge = sub.add_parser("merge", help="merge many layers into one file")
    merge.add_argument("inputs", nargs="+", type=Path)
    merge.add_argument("-o", "--out", type=Path, required=True)
    merge.set_defaults(func=cmd_merge)
    return p

args = build_parser().parse_args()
sys.exit(args.func(args))

Code examples

Example 1: A batch tool that takes a folder and a glob

Accepting a folder plus a pattern makes one tool cover a whole class of jobs.

p.add_argument("src_dir", type=Path, help="folder to scan")
p.add_argument("--pattern", default="*.shp",
               help="glob pattern to match (default: %(default)s)")
p.add_argument("--recursive", action="store_true", help="descend into subfolders")

def main(args):
    finder = args.src_dir.rglob if args.recursive else args.src_dir.glob
    files = sorted(finder(args.pattern))
    if not files:
        print(f"no files matching {args.pattern} in {args.src_dir}", file=sys.stderr)
        return 1
    for path in files:
        process(path, args)
    return 0

Example 2: Combining a config file with flags

Config for the stable settings, flags for what changes per run — with flags winning.

p.add_argument("--config", type=Path, help="YAML config file")
p.add_argument("--crs", help="override params.target_crs from the config")
p.add_argument("--out-dir", type=Path, help="override output.dir from the config")

def resolve(args):
    cfg = load_config(args.config) if args.config else DEFAULT_CONFIG
    if args.crs:
        cfg["params"]["target_crs"] = args.crs        # CLI beats config
    if args.out_dir:
        cfg["output"]["dir"] = args.out_dir
    return cfg

The precedence order — defaults, then config file, then environment, then flags — should be stated in the help text, because ambiguity here is a genuine source of confusion. See driving a pipeline from YAML.

Example 3: Choices, mutually exclusive groups, and repeatable options

Three argparse features that remove a lot of hand-written validation.

p.add_argument("--driver", choices=["GPKG", "GeoJSON", "ESRI Shapefile"],
               default="GPKG", help="output driver (default: %(default)s)")

mode = p.add_mutually_exclusive_group()
mode.add_argument("--quiet", action="store_true")
mode.add_argument("--verbose", action="store_true")

p.add_argument("--column", action="append", dest="columns", default=[],
               help="keep this column; repeat for several")
# clip_tool.py in.gpkg b.geojson --column id --column name

choices produces both validation and help text listing the valid values.

Example 4: Reading from stdin and writing to stdout

Making a tool composable in a shell pipeline costs a few lines and pays off when someone chains it.

p.add_argument("source", nargs="?", type=Path, default=None,
               help="input file; omit or use - to read GeoJSON from stdin")

def main(args):
    if args.source is None or str(args.source) == "-":
        gdf = gpd.read_file(sys.stdin)
    else:
        gdf = gpd.read_file(args.source)

    result = process(gdf, args)
    if args.out is None:
        sys.stdout.write(result.to_json())      # data on stdout …
    else:
        result.to_file(args.out, driver="GPKG")
    print(f"{len(result)} features", file=sys.stderr)   # … messages on stderr
    return 0

Keep data on stdout and messages on stderr, or piping the output into another tool will feed it your log lines.

Example 5: The complete tool

Parser, validation, logging, dry run, exit codes — a template to copy for the next one.

#!/usr/bin/env python3
"""Clip a vector layer to a boundary and write a GeoPackage."""
import argparse, logging, sys
from pathlib import Path
import geopandas as gpd

log = logging.getLogger("clip_tool")

def existing_file(value):
    path = Path(value)
    if not path.exists():
        raise argparse.ArgumentTypeError(f"file not found: {path}")
    return path

def crs_arg(value):
    v = value.upper()
    if not (v.startswith("EPSG:") and v[5:].isdigit()):
        raise argparse.ArgumentTypeError(f"expected EPSG:<code>, got {value!r}")
    return v

def build_parser():
    p = argparse.ArgumentParser(
        prog="clip_tool",
        description=__doc__,
        epilog="Example:\n  clip_tool.py sites.gpkg district.geojson -o out.gpkg -v",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p.add_argument("source", type=existing_file, help="layer to clip")
    p.add_argument("boundary", type=existing_file, help="boundary layer")
    p.add_argument("-o", "--out", type=Path, default=Path("clipped.gpkg"),
                   help="output file (default: %(default)s)")
    p.add_argument("--crs", type=crs_arg, default="EPSG:27700",
                   help="working CRS (default: %(default)s)")
    p.add_argument("-f", "--force", action="store_true", help="overwrite output")
    p.add_argument("-n", "--dry-run", action="store_true", help="write nothing")
    p.add_argument("-v", "--verbose", action="count", default=0, help="-v, -vv")
    return p

def main(args):
    logging.basicConfig(
        level=[logging.WARNING, logging.INFO, logging.DEBUG][min(args.verbose, 2)],
        format="%(levelname)-7s %(message)s",
    )
    if args.out.exists() and not (args.force or args.dry_run):
        print(f"error: {args.out} exists (use --force)", file=sys.stderr)
        return 1

    log.info("reading %s", args.source)
    gdf = gpd.read_file(args.source).to_crs(args.crs)
    boundary = gpd.read_file(args.boundary).to_crs(args.crs)

    clipped = gpd.clip(gdf, boundary)
    log.info("%d → %d features", len(gdf), len(clipped))
    if clipped.empty:
        print("error: clip produced no features — do the layers overlap?",
              file=sys.stderr)
        return 1

    if args.dry_run:
        print(f"dry run: would write {len(clipped)} features to {args.out}")
        return 0

    args.out.parent.mkdir(parents=True, exist_ok=True)
    clipped.to_file(args.out, driver="GPKG")
    print(f"wrote {len(clipped)} features → {args.out}")
    return 0

if __name__ == "__main__":
    sys.exit(main(build_parser().parse_args()))

Explanation

A command-line interface is a contract. It says: here is what this tool needs, here is what it will do, and here is how it reports whether it worked. That contract is what makes a script usable by a colleague, a scheduler, a Makefile, or a CI job — none of which can open your editor and change a constant.

argparse is in the standard library, which matters for a tool that will be run on machines you do not control. It generates --help from the same declarations that do the parsing, so the documentation cannot drift from the behaviour. Third-party libraries like Click and Typer are genuinely nicer for large interfaces with many subcommands; for a handful of arguments, argparse has zero install cost and one less thing to break in a scheduled environment.

Settings resolved in order: built-in defaults, then config file, then environment variables, then command-line flags.
State the precedence order once, in the help text, and never deviate from it.

Validating arguments through type= callables is a small trick with a large payoff. Every check that runs during parsing runs before any file is opened, so the failure is instant and the message is about the argument rather than about GDAL. The general principle — fail at the boundary, in the caller's vocabulary — is the same one behind validating pipeline inputs and outputs.

The reason to keep main(args) free of parsing is that it makes the tool composable in two directions. From the shell, it is a command. From Python, it is a function you can import and call with a namespace you constructed yourself, which is how you test it without spawning subprocesses. That separation is also what lets the same code sit behind a scheduled job tomorrow without a rewrite.

Edge cases or notes

Paths with spaces

Users will pass --out "My Data/out put.gpkg". The shell handles the quoting and argparse hands you the string intact; problems only appear if you re-split the value yourself or build shell commands by string concatenation. Use type=Path and never interpolate a path into a shell string.

Windows and the #! line

The #!/usr/bin/env python3 shebang does nothing on Windows, where the launcher uses the file association instead. Document invocation as python clip_tool.py … in your README so it is copy-pasteable everywhere, and keep the shebang for Unix users.

Boolean flags that need an off switch

action="store_true" gives you a flag that can only be turned on, which is a problem when a config file sets it. Python 3.9+ has action=argparse.BooleanOptionalAction, which generates both --overwrite and --no-overwrite, so a flag can override a config in either direction.

Very many inputs

nargs="+" collects a list of files, but shells impose a limit on command length, and a folder of 5,000 shapefiles will exceed it. Accept a folder plus a --pattern, or a --file-list pointing at a text file of paths, and let the tool do the discovery — see batch process a folder of GIS files.

Progress output when the tool is not interactive

Progress bars written to stdout become thousands of junk lines in a scheduled job's log. Check sys.stderr.isatty() and fall back to one line per N files when the output is not a terminal.

Do not swallow the traceback

Catching every exception to print a tidy message is right for expected failures — a missing file, an empty result. For unexpected ones, log the traceback with logging.exception() before returning a non-zero code. A friendly message with no traceback is a debugging dead end at three in the morning.

FAQ

Should I use argparse, Click, or Typer?

Use argparse unless you have a reason not to: it is in the standard library, so a scheduled job on a bare server needs no extra install, and it covers subcommands, types, and generated help. Click and Typer are more pleasant for large multi-command tools and give nicer help output; the trade is one more dependency in every environment that runs the job.

What is the difference between a positional argument and an option?

A positional is required and identified by its place in the command; an option is named, usually optional, and has a default. The test is whether a sensible default exists. Input files are positionals because the tool cannot invent them; output paths, CRSs, and thresholds are options because a reasonable default exists.

How do I validate an argument before the script runs?

Pass a callable to type=. It receives the raw string and either returns the converted value or raises argparse.ArgumentTypeError, which argparse turns into a clean usage error and exit code 2. This runs during parsing, so bad input fails in milliseconds rather than after a long read.

What exit code should my tool return?

0 for success and non-zero for failure — that is the only thing schedulers and shells check. Argparse already exits with 2 for usage errors, so use 1 for runtime failures. Returning 0 after a failure is worse than crashing, because a scheduled job will report success and nobody will look at the log.

How should a --dry-run behave?

It should do everything except write: read the inputs, run the analysis, and report exactly what it would produce and where. A dry run that only prints the intended output path verifies almost nothing. Make it also check that the output does not already exist, so a real run cannot fail on something a dry run could have caught.

Can I call the tool from another Python script?

Yes, if you keep parsing separate from the work. Import build_parser and main, construct a namespace with build_parser().parse_args([...]), and call main(args). No subprocess, no shell quoting, and the same code path as the command line — which is also how you test it.

How do I stop a scheduled run from overwriting yesterday's output?

Refuse to overwrite unless --force is passed, and default the output path to something dated. Together these mean an accidental rerun fails safely instead of destroying a good result, while the scheduled job stays explicit about which behaviour it wants.