Sooner or later you write a Python script useful enough that you want to run it with different options instead of editing the code each time. That is the moment to reach for argparse. Building a Python CLI with argparse is one of those skills that feels fiddly for about ten minutes and then becomes second nature you use forever.
Why not just read sys.argv?
You can read arguments straight from sys.argv, but you will end up hand-writing parsing, validation, help text, and error messages — badly. argparse is in the standard library and gives you all of that for free, including a polished --help output that makes your script feel like a real tool.
A minimal example
Here is a complete little CLI that greets someone a number of times:
import argparse
parser = argparse.ArgumentParser(
description="Greet someone by name.")
parser.add_argument("name",
help="who to greet")
parser.add_argument("-c", "--count", type=int, default=1,
help="how many times to greet")
args = parser.parse_args()
for _ in range(args.count):
print(f"Hello, {args.name}!")
Run it as python greet.py Sam --count 3 and it prints the greeting three times. That is a real, usable command-line program in a dozen lines.
Positional vs optional arguments
Notice the two styles above. name is positional — required, given by position. --count is optional — it has a flag and a default, so the user can leave it out. As a rule, make the essential inputs positional and everything tweakable an option with a sensible default. Your users should be able to run the common case with the fewest words.
The niceties you get for free
Because you declared types and help strings, argparse handles the annoying parts automatically. Pass --count abc and it rejects it with a clear error instead of crashing deep in your loop. Run python greet.py --help and it prints usage and descriptions you never had to format. Flags like store_true give you clean boolean switches:
parser.add_argument("--verbose", action="store_true",
help="print extra detail")
Subcommands: when your tool grows verbs
Real tools rarely stay single-purpose. The moment your script wants to do two related things — backup create and backup restore, say — you want subparsers, the same pattern git and docker use:
parser = argparse.ArgumentParser(prog="backup")
sub = parser.add_subparsers(dest="command", required=True)
create = sub.add_parser("create", help="make a new backup")
create.add_argument("target", help="directory to back up")
create.add_argument("--compress", action="store_true")
restore = sub.add_parser("restore", help="restore from a backup")
restore.add_argument("archive", help="backup file to restore")
args = parser.parse_args()
if args.command == "create":
do_create(args.target, args.compress)
elif args.command == "restore":
do_restore(args.archive)
Each subcommand gets its own arguments and its own --help, so backup create --help shows only what’s relevant to creating. The dest="command" gives you a clean string to dispatch on. This structure scales from two subcommands to twenty without becoming spaghetti — it’s the single most useful argparse feature that beginners don’t know exists.
Validation beyond types
The type= parameter accepts any callable, which turns it into a validation hook. Want an argument that must be an existing file, or a number in a range? Write a tiny function that either returns the cleaned value or raises argparse.ArgumentTypeError:
def existing_file(path):
p = pathlib.Path(path)
if not p.is_file():
raise argparse.ArgumentTypeError(f"{path} does not exist")
return p
parser.add_argument("config", type=existing_file)
Now bad input dies at the door with a proper usage message instead of a traceback three functions deep. A few other high-value options in the same spirit: choices=["dev", "prod"] restricts values to a whitelist and documents them in the help text automatically; nargs="+" accepts one-or-more values into a list (process.py file1 file2 file3); and default=os.environ.get("API_URL") lets an environment variable supply the default while the flag overrides it — a pattern that makes scripts pleasant both interactively and in cron jobs.
Exit codes and stderr: the polish that makes it scriptable
If other programs (or CI pipelines) will run your tool, two conventions matter. Errors should go to stderr, not stdout, so they don’t pollute output that another program might be parsing — and your script should exit non-zero on failure so shell scripts and CI can detect it. parser.error("message") does both correctly in one call, printing usage plus your message and exiting with code 2. For failures later in the run, sys.exit("something went wrong") prints to stderr and exits 1. Tools that get this right compose beautifully with pipes, && chains, and Makefiles; tools that print errors to stdout and exit 0 no matter what become the reason a deploy script “succeeded” while doing nothing.
Frequently asked questions
Should I use argparse or a library like Click or Typer? For anything you’ll share or ship, the third-party libraries are genuinely nicer — Typer in particular turns type-annotated functions straight into CLIs. But argparse ships with Python, needs zero installation, and is what you’ll find in most existing codebases and system scripts. Learn argparse first because it’s always available; graduate when a project justifies a dependency.
How do I test an argparse CLI? Don’t shell out to your own script — call parser.parse_args(["create", "docs", "--compress"]) with an explicit list in your tests. Structuring your script so parsing lives in a main(argv=None) function makes this trivial and is good hygiene anyway.
Why does my script parse arguments when imported? Because parse_args() runs at module top level. Guard it with if __name__ == "__main__": so importing the file for testing doesn’t trigger the parser — the classic Python idiom exists for exactly this.
The takeaway
Whenever a script grows past “edit the variables at the top,” give it a proper interface. Building a Python CLI with argparse takes only a few lines, turns your one-off scripts into reusable tools, and makes them pleasant for other people — and forgetful future-you — to actually run.

