argparse is Python's standard-library module for turning terminal arguments into Python values, generating help, and reporting usage errors. The direct answer is: create an ArgumentParser, declare arguments and options, call parse_args(), then dispatch the result to independent functions. Use subcommands when an application has distinct actions, and publish an entry point in pyproject.toml when users should run it by name.
This tutorial builds tasks, a CLI that adds and lists items in a JSON file. It includes positional arguments, short and long options, subcommands, restricted values, and machine-readable output. Because argparse ships with Python, it adds no dependency. Use a virtual environment with venv so the installed command remains isolated while you develop it.
Positional arguments and options
A positional argument is identified by its place in the command. An option begins with - or --, is usually optional, and can have a default. This minimal parser receives a file and lets users choose output behavior:
import argparse
parser = argparse.ArgumentParser(description="Summarize a task file")
parser.add_argument("file", help="path to the JSON file")
parser.add_argument("-f", "--format", choices=["text", "json"], default="text")
parser.add_argument("-v", "--verbose", action="store_true")
args = parser.parse_args()
print(args.file, args.format, args.verbose)
choices rejects unknown values and displays allowed ones in help. store_true defaults to False and becomes True when the flag is present. Use type=int or a custom callable to convert input, remembering that shell input initially arrives as text. The official argparse reference documents every action and parameter.
Run python app.py data.json --format json -v, then inspect python app.py --help. Helpful output is part of the interface, not decoration. Clear names, concise descriptions, and visible defaults keep users away from source code just to discover basic usage.
Practical project and structure
Create this layout:
task-manager/
├── pyproject.toml
├── src/
│ └── tasks_cli/
│ ├── __init__.py
│ └── cli.py
└── tests/
└── test_cli.py
Code belongs in src/tasks_cli/cli.py. A build_parser function assembles the interface, main parses input, and command functions implement behavior. This separation makes testing straightforward and keeps parser details out of domain logic. Review our Python modules and packages guide if this package layout is unfamiliar.
import argparse
import json
from pathlib import Path
from typing import Sequence
PRIORITIES = ("low", "medium", "high")
def load(path: Path) -> list[dict]:
if not path.exists():
return []
return json.loads(path.read_text(encoding="utf-8"))
def save(path: Path, tasks: list[dict]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(tasks, indent=2), encoding="utf-8")
def add(args: argparse.Namespace) -> int:
tasks = load(args.file)
tasks.append({"title": args.title, "priority": args.priority})
save(args.file, tasks)
print(f"Task added: {args.title}")
return 0
def list_tasks(args: argparse.Namespace) -> int:
tasks = load(args.file)
selected = [
item for item in tasks
if args.priority is None or item["priority"] == args.priority
]
if args.as_json:
print(json.dumps(selected, indent=2))
else:
for index, item in enumerate(selected, start=1):
print(f"{index}. [{item['priority']}] {item['title']}")
return 0
Path avoids manual path concatenation; our Python pathlib guide covers it in depth. Each command returns an integer exit status. Zero communicates success, while nonzero values let shells, CI jobs, and scripts detect failure without parsing prose.
Subcommands with dedicated parsers
add_subparsers() creates child commands. Each child receives only relevant arguments and registers its handler through set_defaults(func=...):
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="tasks",
description="Manage tasks in a JSON file.",
)
parser.add_argument(
"--file",
type=Path,
default=Path("tasks.json"),
help="data file (default: tasks.json)",
)
subparsers = parser.add_subparsers(dest="command", required=True)
add_parser = subparsers.add_parser("add", help="create a task")
add_parser.add_argument("title", help="task text")
add_parser.add_argument("-p", "--priority", choices=PRIORITIES, default="medium")
add_parser.set_defaults(func=add)
list_parser = subparsers.add_parser("list", help="show tasks")
list_parser.add_argument("-p", "--priority", choices=PRIORITIES)
list_parser.add_argument("--json", dest="as_json", action="store_true")
list_parser.set_defaults(func=list_tasks)
return parser
def main(argv: Sequence[str] | None = None) -> int:
args = build_parser().parse_args(argv)
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main())
Optional argv is deliberate. In production, None tells argparse to read sys.argv; in tests, a list gives complete control. The final block permits python -m tasks_cli.cli without running the CLI merely because another module imported it. Our article on __name__ and __main__ explains this pattern.
Try the interface:
python -m tasks_cli.cli add "Review report" --priority high
python -m tasks_cli.cli list
python -m tasks_cli.cli --file team.json list --json
Main-parser options usually need to appear before the subcommand. If --file must also work afterward, define it on child parsers or use a shared parent parser. Avoid supporting multiple orders without a real need; predictable grammar is easier to document and maintain.
Validation and useful errors
Use choices for closed sets and type for conversion. A custom type function can enforce a condition and raise ArgumentTypeError:
def positive_integer(value: str) -> int:
number = int(value)
if number < 1:
raise argparse.ArgumentTypeError("must be greater than zero")
return number
Do not catch the SystemExit generated by syntax errors in normal application code. It supplies status 2 and consistent usage text. Operational problems such as malformed JSON or denied file access should be caught near main, written to stderr, and converted into a suitable status. See the Python error-handling guide for focused exception boundaries.
import sys
def main(argv: Sequence[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
return args.func(args)
except (OSError, json.JSONDecodeError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
Avoid except Exception solely to print generic text. It conceals programming defects, makes debugging difficult, and may leave users with a corrupted or partially written file.
An installable entry point
An entry point maps the tasks command to a Python callable. A minimal pyproject.toml looks like this:
[build-system]
requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta"
[project]
name = "tasks-cli"
version = "0.1.0"
requires-python = ">=3.10"
[project.scripts]
tasks = "tasks_cli.cli:main"
Install it in editable mode with python -m pip install -e ., then run tasks --help. Do not add parentheses after main; the installer generates a wrapper that invokes the callable. The PyPA entry points specification defines this mapping, while the official argparse HOWTO shows how simple interfaces evolve. Read publishing a Python package to PyPI when the command is ready for distribution.
Testing the CLI
Test main without a subprocess for fast feedback and capture output with pytest's capsys:
from tasks_cli.cli import main
def test_add_and_list(tmp_path, capsys):
path = tmp_path / "tasks.json"
assert main(["--file", str(path), "add", "Study"]) == 0
capsys.readouterr()
assert main(["--file", str(path), "list"]) == 0
output = capsys.readouterr().out
assert "[medium] Study" in output
def test_invalid_priority_exits():
import pytest
with pytest.raises(SystemExit) as error:
main(["add", "Study", "--priority", "urgent"])
assert error.value.code == 2
One integration test may execute the installed command, but most behavior should stay in ordinary functions. The Python pytest guide demonstrates fixtures and parametrization for success, invalid syntax, corrupt storage, filters, and JSON output.
Compatibility, output, and automation
A CLI becomes a public interface as soon as another script invokes it. Renaming an option, changing an output field, or reusing an exit status can therefore break automation even when the business logic remains correct. Prefer additive changes and document a deprecation period before removing established behavior. When programs consume the command, provide a stable mode such as --format json instead of asking them to parse prose intended for people.
Use a straightforward stream convention: regular results go to stdout, diagnostics go to stderr, and zero means success. argparse already exits with status 2 for invalid command syntax; an operational failure can return 1. Keeping the streams separate lets a caller redirect useful data without mixing it with warnings:
tasks list --format json > tasks.json
Test --help whenever the parser changes. Confirm that every subcommand states its purpose, Boolean options make their defaults apparent, and examples use syntax the parser actually accepts. Avoid putting secrets in command-line arguments because values can be retained in shell history or exposed in a process listing. Read tokens and passwords from an appropriate source, such as an environment variable or secure prompt. These choices turn a convenient script into a predictable interface for terminals, CI pipelines, and scheduled jobs.
Frequently asked questions
Must argparse be installed?
No. It belongs to Python's standard library. Install dependencies only for other capabilities your application needs.
When should I use a positional argument or an option?
Use a few positional arguments for essential input. Use options for optional configuration, flags, and values with sensible defaults.
Are subcommands better than several flags?
Yes when actions accept different inputs. add and list are clearer than conflicting combinations such as --add --list.
Are an entry point and a __main__ block identical?
No. An entry point creates a command after installation; the block supports direct module execution. Both can delegate to the same main.
Conclusion
A dependable CLI separates parsing from business logic, provides readable help, validates input, and returns meaningful statuses. With subcommands, injectable main(argv), tests, and [project.scripts], this small manager works for humans and automation. Start with the smallest useful grammar and treat command names, output, and compatibility as a public API.