argparse is the argument parser in the Python standard library, and for a surprising
number of tools it is all you need. It ships with every interpreter, so a CLI built on it
has zero third-party dependencies — nothing to pin, nothing to break on a pip
resolution, nothing extra to audit. This overview shows you how to build a real parser with
it, validate input properly, and recognize the point where a heavier framework earns its
place.
TL;DR
- Create a parser with
argparse.ArgumentParser, add positional arguments withadd_argument("name")and optional ones withadd_argument("--flag"). - Coerce and validate with
type=, constrain withchoices=, set fallbacks withdefault=, and collect multiples withnargs=. Boolean flags useaction="store_true". parse_args()returns a plainNamespace; read values as attributes.- Reach for subparsers for git-style subcommands.
- Graduate to Click or Typer once you want nested groups, shell completion, and less boilerplate — and when you do, follow the argparse-to-Typer migration guide.
Why start with argparse
Every third-party CLI framework has to justify a dependency. argparse never does — it is
part of Python, documented alongside the language, and stable across releases. If you are
writing an internal script, a build helper, or a tool that must run in a locked-down
environment where installing packages is painful, the calculus is simple: reach for the
stdlib first.
It also teaches you the model that Click and Typer sit on top of. Positional vs optional
arguments, type coercion, nargs, and subcommand dispatch are the same ideas everywhere;
argparse just makes you spell them out. Learn it here and the frameworks feel like
shortcuts rather than magic.
A runnable parser
Here is a complete program — a small file-copier — that uses a positional argument, a typed
option, and a boolean flag. Save it as mvcp.py and run it directly.
# mvcp.py
import argparse
from pathlib import Path
def main() -> None:
parser = argparse.ArgumentParser(
prog="mvcp",
description="Copy a file, optionally renaming it.",
)
parser.add_argument("source", type=Path, help="File to copy.")
parser.add_argument(
"--dest-dir",
type=Path,
default=Path.cwd(),
help="Directory to copy into (default: current directory).",
)
parser.add_argument(
"--overwrite",
action="store_true",
help="Replace the destination if it already exists.",
)
args = parser.parse_args()
target = args.dest_dir / args.source.name
if target.exists() and not args.overwrite:
parser.error(f"{target} exists; pass --overwrite to replace it")
print(f"Would copy {args.source} -> {target}")
if __name__ == "__main__":
main()
$ python mvcp.py notes.txt --dest-dir backup/
Would copy notes.txt -> backup/notes.txt
$ python mvcp.py notes.txt --dest-dir backup/
mvcp.py: error: backup/notes.txt exists; pass --overwrite to replace it
Three things are happening. source has no leading dash, so it is positional and
required. --dest-dir starts with dashes, so it is optional and takes a value.
--overwrite is a flag — action="store_true" means it defaults to False and flips
to True when present, taking no value.
Notice type=Path. argparse calls that callable on the raw string, so args.source is a
pathlib.Path, not a str. Any one-argument callable works here, which is the hook you
will use for validation below.
choices, default, nargs, and flags
These four knobs cover the vast majority of real arguments.
parser.add_argument(
"--log-level",
choices=["debug", "info", "warning", "error"],
default="info",
help="Verbosity (default: info).",
)
parser.add_argument(
"paths",
nargs="+", # one or more, collected into a list
type=Path,
help="One or more files to process.",
)
parser.add_argument(
"--tag",
action="append", # repeatable: --tag a --tag b -> ["a", "b"]
default=[],
help="Attach a tag; repeat for several.",
)
parser.add_argument("--dry-run", action="store_true")
choicesrestricts a value to a fixed set.argparserejects anything else before your code runs and lists the valid options in the error, so you never validate the enum by hand.defaultsupplies a value when the flag is absent. Optionals without a default getNone.nargscontrols how many values an argument consumes:"+"(one or more),"*"(zero or more),"?"(optional single), or an integer for an exact count.nargs="+"on a positional is how you accept a list of files.action="store_true"for boolean switches,action="append"to collect a repeatable option into a list.
Validation with type= callables and parser.error()
type= is not just for int and Path — any callable that takes a string and either
returns a value or raises is fair game. Raise argparse.ArgumentTypeError (or ValueError)
and argparse turns it into a clean, non-zero-exit error message instead of a traceback.
import argparse
def positive_int(raw: str) -> int:
value = int(raw) # ValueError here is caught by argparse too
if value <= 0:
raise argparse.ArgumentTypeError(f"{raw!r} is not a positive integer")
return value
parser = argparse.ArgumentParser()
parser.add_argument("--workers", type=positive_int, default=4)
$ python app.py --workers 0
app.py: error: argument --workers: '0' is not a positive integer
For validation that spans several arguments (say, "--end must be after --start"), do
it after parsing and report failures through parser.error(), which prints to stderr and
exits with status 2 — the conventional argparse usage-error code:
args = parser.parse_args()
if args.end <= args.start:
parser.error("--end must be later than --start")
Using parser.error() rather than print(); sys.exit() keeps your error output consistent
with the parser's own messages. For the broader picture of exit statuses, see
choosing exit codes for CLI tools.
Subcommands: a first look
Once a tool does more than one job — tool build, tool deploy, tool clean — you want
subcommands, each with its own arguments and help. argparse provides these through
add_subparsers():
parser = argparse.ArgumentParser(prog="tool")
sub = parser.add_subparsers(dest="command", required=True)
build = sub.add_parser("build", help="Build the project.")
build.add_argument("--release", action="store_true")
deploy = sub.add_parser("deploy", help="Deploy the project.")
deploy.add_argument("target")
args = parser.parse_args()
That is enough to give tool build --release and tool deploy prod their own parsers. The
clean way to route each subcommand to a handler function — with set_defaults(func=...),
shared parent parsers, and nesting — is a topic of its own:
argparse subparsers for subcommands.
Help output for free
You never write --help. argparse builds usage text from your prog, description, and
every argument's help string, and wires up -h/--help automatically:
$ python mvcp.py --help
usage: mvcp [-h] [--dest-dir DEST_DIR] [--overwrite] source
Copy a file, optionally renaming it.
positional arguments:
source File to copy.
options:
-h, --help show this help message and exit
--dest-dir DEST_DIR Directory to copy into (default: current directory).
--overwrite Replace the destination if it already exists.
Add an epilog= for examples, and set formatter_class=argparse.RawDescriptionHelpFormatter
if you want to control the wrapping of your description yourself.
When to graduate to Click or Typer
argparse starts to fight you at a predictable point. The trade-offs:
| Concern | argparse | Click / Typer |
|---|---|---|
| Dependencies | None (stdlib) | One framework |
| Boilerplate | High — every arg spelled out | Low, especially with Typer's type hints |
| Nested subcommands | Manual and verbose | First-class groups |
| Shared context between commands | Roll your own | ctx.obj / dependency injection |
| Shell completion | Not built in | Built in |
| Rich help, colors, prompts | Manual | Included |
If your tool is a handful of commands with simple options, argparse is the right tool and
adding a dependency is over-engineering. Once you find yourself hand-rolling subcommand
dispatch, wanting tab completion, or copy-pasting the same global options onto every
command, a framework pays for itself. Start with
Typer vs Click: when to use each
to pick one, then either
build subcommands in Click
or follow the migration path to Typer.
Production notes
- Namespace is intentionally dumb.
parse_args()returns anargparse.Namespacewith no validation of its own. For a typed object, feed it into a dataclass:Config(**vars(args)). That gives you editor autocompletion and mypy coverage downstream. - Test without a subprocess. Call
parser.parse_args(["notes.txt", "--overwrite"])with an explicit list in unit tests — it readssys.argvonly when you passNone. Assert on the returned namespace directly. - Hyphens become underscores.
--dest-diris available asargs.dest_dir.argparsetranslates automatically; do not look forargs["dest-dir"]. - Exit code 2 for usage errors.
parser.error()and unknown-argument failures exit with status 2, a convention worth preserving if you later migrate. See structuring multi-command Python CLIs for keeping parsing separate from logic so a future framework swap stays cheap. parse_known_args()returns a(namespace, leftovers)tuple when you need to forward unrecognized flags to a wrapped tool, rather than erroring on them.