A command-line interface turns a Python script into a tool that can be controlled from a terminal. Instead of editing source code whenever an input changes, users pass filenames, flags, formats, limits, and subcommands when they run the program. The standard-library argparse module handles this parsing and generates help and error messages automatically.
This guide builds a Python CLI with argparse from a single positional argument to a complete file-statistics application. You will learn optional arguments, Boolean flags, types, choices, defaults, required options, mutually exclusive options, subcommands, validation, testing, and modern packaging with pyproject.toml.
For lower-level command-line access, the Python sys module guide explains sys.argv. Argparse reads that list for you and provides a structured interface on top of it.
Why Use argparse?
A hand-written parser can read sys.argv, but it must implement help, missing-value checks, type conversion, usage text, option aliases, and invalid-input errors. Argparse centralizes those responsibilities.
The official Python argparse documentation describes the parser, actions, formatters, subparsers, and error behavior. Argparse is included with Python; no package installation is required.
Create a Minimal CLI
Save this file as greet.py:
import argparse
parser = argparse.ArgumentParser(
description="Print a personalized greeting."
)
parser.add_argument("name", help="Name of the person to greet")
args = parser.parse_args()
print(f"Hello, {args.name}!")Run it:
python greet.py AvaArgparse stores the positional value in args.name. Run python greet.py --help to see generated usage and help text.
Positional and Optional Arguments
A positional argument is identified by its location:
parser.add_argument("filename")An optional argument begins with a flag:
parser.add_argument("-o", "--output")The short and long forms refer to the same value. Long descriptive names improve scripts and documentation; short forms are convenient for frequent terminal use.
Convert Types
Command-line input starts as text. Use type to convert and validate common values:
parser.add_argument(
"--limit",
type=int,
default=10,
help="Maximum number of results (default: 10)",
)When the user supplies invalid text, argparse prints an error and exits instead of passing a bad value into the application.
Use pathlib.Path for paths
from pathlib import Path
parser.add_argument("input_file", type=Path)This creates a Path object directly. The Python pathlib guide covers existence checks, reading, writing, and safe path handling.
Create Boolean Flags
A flag can become true when it appears:
parser.add_argument(
"-v",
"--verbose",
action="store_true",
help="Show detailed output",
)Without the flag, args.verbose is false. With --verbose, it is true.
For a setting that defaults to true and can be disabled, modern argparse also supports Boolean optional actions:
parser.add_argument(
"--color",
action=argparse.BooleanOptionalAction,
default=True,
)This creates forms such as --color and --no-color.
Restrict Values with choices
parser.add_argument(
"--format",
choices=["text", "json", "csv"],
default="text",
)Argparse rejects values outside the list and displays the accepted choices in the help output.
Accept Several Values
Use nargs when one argument accepts multiple values:
parser.add_argument(
"files",
nargs="+",
type=Path,
help="One or more files",
)| nargs | Meaning |
|---|---|
? | Zero or one value. |
* | Zero or more values. |
+ | One or more values. |
| Integer | Exactly that number of values. |
Repeat an Option with action=”append”
parser.add_argument(
"--tag",
action="append",
default=[],
help="Add a tag; repeat the option for several tags",
)Running --tag python --tag cli produces ["python", "cli"].
Make Options Mutually Exclusive
When two modes cannot be active together, let argparse enforce the rule:
output_group = parser.add_mutually_exclusive_group()
output_group.add_argument("--quiet", action="store_true")
output_group.add_argument("--verbose", action="store_true")Passing both options produces a clear usage error.
Separate Parser Creation from Application Logic
A parser-building function makes testing and reuse easier:
def build_parser():
parser = argparse.ArgumentParser(
description="Inspect a text file."
)
parser.add_argument("path", type=Path)
parser.add_argument("--encoding", default="utf-8")
return parser
def main(argv=None):
args = build_parser().parse_args(argv)
print(args.path, args.encoding)Passing argv lets a test provide arguments directly instead of modifying the process-wide sys.argv.
Validate Values That Need Custom Logic
A type function can reject invalid values with ArgumentTypeError:
def positive_integer(value):
try:
number = int(value)
except ValueError as error:
raise argparse.ArgumentTypeError(
f"{value!r} is not an integer"
) from error
if number < 1:
raise argparse.ArgumentTypeError(
"the value must be at least 1"
)
return numberparser.add_argument("--limit", type=positive_integer)Use parser-level validation for relationships between several arguments after parsing.
Build Subcommands
Subcommands organize related operations such as add, list, and remove.
parser = argparse.ArgumentParser(prog="tasks")
subparsers = parser.add_subparsers(dest="command", required=True)
add_parser = subparsers.add_parser("add", help="Add a task")
add_parser.add_argument("title")
list_parser = subparsers.add_parser("list", help="List tasks")
list_parser.add_argument("--completed", action="store_true")
remove_parser = subparsers.add_parser("remove", help="Remove a task")
remove_parser.add_argument("task_id", type=int)Dispatch with the selected command, or attach a handler function to each subparser:
def handle_add(args):
print(f"Adding: {args.title}")
add_parser.set_defaults(handler=handle_add)
args = parser.parse_args()
args.handler(args)Complete Project: File Statistics CLI
The following tool counts lines, words, and characters in one or more text files. It supports JSON output, recursive directory searching, encoding selection, verbose logging, and a progress bar.
import argparse
import json
import logging
from pathlib import Path
def positive_integer(value):
try:
number = int(value)
except ValueError as error:
raise argparse.ArgumentTypeError(
f"{value!r} is not an integer"
) from error
if number < 1:
raise argparse.ArgumentTypeError(
"the value must be at least 1"
)
return number
def build_parser():
parser = argparse.ArgumentParser(
prog="textstats",
description="Count lines, words, and characters in text files.",
epilog=(
"Example: textstats notes.txt --format json"
),
)
parser.add_argument(
"paths",
nargs="+",
type=Path,
help="Files or directories to process",
)
parser.add_argument(
"-r",
"--recursive",
action="store_true",
help="Search directories recursively",
)
parser.add_argument(
"--pattern",
default="*.txt",
help="File pattern used for directories",
)
parser.add_argument(
"--encoding",
default="utf-8",
help="Text encoding (default: utf-8)",
)
parser.add_argument(
"--format",
choices=["text", "json"],
default="text",
)
parser.add_argument(
"--limit",
type=positive_integer,
help="Process at most this many files",
)
parser.add_argument(
"-v",
"--verbose",
action="store_true",
)
parser.add_argument(
"--version",
action="version",
version="%(prog)s 1.0.0",
)
return parser
def collect_files(paths, pattern, recursive):
files = []
for path in paths:
if path.is_file():
files.append(path)
continue
if path.is_dir():
iterator = (
path.rglob(pattern)
if recursive
else path.glob(pattern)
)
files.extend(item for item in iterator if item.is_file())
continue
logging.warning("Path not found: %s", path)
return sorted(set(files))
def inspect_file(path, encoding):
text = path.read_text(encoding=encoding, errors="replace")
return {
"path": str(path),
"lines": len(text.splitlines()),
"words": len(text.split()),
"characters": len(text),
}
def format_text(results):
for result in results:
print(result["path"])
print(f" lines: {result['lines']}")
print(f" words: {result['words']}")
print(f" characters: {result['characters']}")
def main(argv=None):
args = build_parser().parse_args(argv)
logging.basicConfig(
level=logging.INFO if args.verbose else logging.WARNING,
format="%(levelname)s: %(message)s",
)
files = collect_files(
args.paths,
args.pattern,
args.recursive,
)
if args.limit is not None:
files = files[: args.limit]
if not files:
raise SystemExit("No matching files found.")
logging.info("Processing %d files", len(files))
results = [inspect_file(path, args.encoding) for path in files]
if args.format == "json":
print(json.dumps(results, indent=2))
else:
format_text(results)
return 0
if __name__ == "__main__":
raise SystemExit(main())Example commands:
python textstats.py notes.txt
python textstats.py documents --recursive
python textstats.py documents -r --format json --limit 20
python textstats.py --helpThe script uses the Python logging module for diagnostic output and keeps normal results on standard output.
Add tqdm Progress
After installing tqdm, replace the result comprehension with:
from tqdm import tqdm
results = [
inspect_file(path, args.encoding)
for path in tqdm(files, desc="Reading", unit="file")
]The Python tqdm guide covers quiet modes and non-interactive terminals.
Test Argument Parsing
from pathlib import Path
from textstats import build_parser
def test_parser_reads_options():
args = build_parser().parse_args(
["notes.txt", "--format", "json", "--limit", "5"]
)
assert args.paths == [Path("notes.txt")]
assert args.format == "json"
assert args.limit == 5Test business logic separately from parsing. The Python unit testing guide explains temporary files, mocks, and subprocess tests.
Test the Real Command with subprocess
import subprocess
import sys
def test_help_command():
result = subprocess.run(
[sys.executable, "textstats.py", "--help"],
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0
assert "Count lines" in result.stdoutThis checks the real executable behavior, including exit codes and generated help.
Package the CLI with pyproject.toml
Modern Python packages normally declare command entry points in pyproject.toml rather than requiring a legacy setup.py.
[project]
name = "textstats-cli"
version = "0.1.0"
description = "Text file statistics command"
requires-python = ">=3.10"
[project.scripts]
textstats = "textstats.cli:main"After the package is installed, the environment creates a textstats command that calls textstats.cli.main. The official Python Packaging User Guide explains executable scripts and project metadata. The installable Python package guide covers a complete package structure.
Exit Codes
A successful command normally exits with zero. Invalid argparse input exits with a nonzero code automatically. Application errors can use raise SystemExit(message) or return an integer from main() and pass it to SystemExit.
Do not use exceptions as ordinary control flow everywhere. Reserve nonzero exits for cases where the command could not complete as requested.
Common Mistakes
- Parsing arguments at import time, which makes tests and reuse harder.
- Using
type=boolfor flags. Use actions such asstore_true. - Putting all application logic directly after
parse_args(). - Failing to include useful help text and examples.
- Using string paths everywhere when
Pathwould be clearer. - Printing errors to normal output when scripts need to pipe results.
- Publishing a CLI with only a legacy
setup.pyexample. - Testing only happy paths and ignoring missing or invalid arguments.
Frequently Asked Questions
Do I need to install argparse?
No. It is part of the standard library.
How do I create a flag with no value?
Use action="store_true", store_false, or BooleanOptionalAction.
How do I make a subcommand required?
Use add_subparsers(required=True) on supported modern Python versions.
Can I parse a custom argument list?
Yes. Pass a list to parse_args(list_of_strings). Without it, argparse reads sys.argv.
How do I show a version?
Add an argument with action="version".
When should I use Click or Typer instead?
Argparse is dependency-free and capable. Third-party frameworks may offer decorators, rich output, type-hint-driven commands, or different composition models for larger applications.
Conclusion
A Python CLI with argparse gains reliable parsing, automatic help, type conversion, choices, flags, and subcommands without an external dependency. The most maintainable design builds the parser in one function, keeps business logic elsewhere, and lets main(argv=None) accept test arguments.
Begin with one positional argument and one flag. Add validation, subcommands, tests, and packaging only as the tool grows. A small, predictable command is more useful than a complicated interface with unclear behavior.






