Building a CLI Tool in Python with argparse
A step-by-step build of a small command-line tool with Python's argparse: positional arguments and typed options, choices and flags, subcommands, what happens on bad input, how to test the parser, and how to install the tool as a command with pyproject.toml.
GloryTecks Team
Training & Placement Faculty
Last updated: 24 September 2026
A script becomes a tool the moment someone else runs it, including you in three months. They should not have to open the file to change a path or a threshold. Python's standard library already has everything needed: argparse reads the command line, converts and validates values, prints help, and exits with a proper error code when the input is wrong (Python docs: argparse).
This walkthrough builds csvtool, a small command with two subcommands: stats summarises a numeric column of a CSV file, and head prints the first rows.
Step 1: the smallest useful parser
Start with one positional argument (required, identified by position) and one option (identified by its flag, optional, with a default):
import argparse
parser = argparse.ArgumentParser(prog="csvtool", description="Quick checks on CSV files.")
parser.add_argument("path", help="CSV file to read")
parser.add_argument("--column", default="amount", help="numeric column to summarise (default: %(default)s)")
args = parser.parse_args(["sales.csv", "--column", "revenue"])
print(args)Namespace(path='sales.csv', column='revenue')parse_args() normally reads sys.argv. Passing a list, as here, is how you try a parser in a notebook or a test. The %(default)s placeholder puts the default into the help text, so the two never disagree.
Step 2: types, choices and flags
Arguments arrive as strings. Give type= a function and argparse converts the value, rejecting input that fails. choices= restricts a value to a fixed set, and action="store_true" makes a flag that takes no value:
parser.add_argument("--top", type=int, default=5, help="rows to show")
parser.add_argument("--delimiter", choices=[",", ";", "\t"], default=",")
parser.add_argument("--quiet", action="store_true", help="print only the result")
print(parser.parse_args(["sales.csv", "--top", "10", "--quiet"]))Namespace(path='sales.csv', column='amount', top=10, delimiter=',', quiet=True)--top came back as the integer 10, not the string "10", so the rest of the program never has to convert it.
Step 3: subcommands
Tools such as git have subcommands, each with its own arguments. add_subparsers() builds that structure, and set_defaults(func=...) attaches the function each subcommand should run:
import csv
import statistics
def cmd_stats(args):
with open(args.path, newline="", encoding="utf-8") as f:
values = [float(row[args.column]) for row in csv.DictReader(f) if row[args.column]]
print(f"{args.column}: n={len(values)} mean={statistics.mean(values):.2f} max={max(values):.2f}")
def cmd_head(args):
with open(args.path, newline="", encoding="utf-8") as f:
for i, line in enumerate(f):
if i > args.rows:
break
print(line.rstrip())
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="csvtool", description="Quick checks on CSV files.")
sub = parser.add_subparsers(dest="command", required=True)
stats = sub.add_parser("stats", help="summarise a numeric column")
stats.add_argument("path")
stats.add_argument("--column", default="amount")
stats.set_defaults(func=cmd_stats)
head = sub.add_parser("head", help="print the first rows")
head.add_argument("path")
head.add_argument("-n", "--rows", type=int, default=5)
head.set_defaults(func=cmd_head)
return parser
def main(argv=None) -> int:
args = build_parser().parse_args(argv)
args.func(args)
return 0
with open("sales.csv", "w", newline="", encoding="utf-8") as f:
f.write("region,amount\nnorth,120\nsouth,80\neast,60\n")
main(["stats", "sales.csv"])
main(["head", "sales.csv", "-n", "2"])amount: n=3 mean=86.67 max=120.00
region,amount
north,120
south,80required=True on the subparsers means running csvtool with no subcommand is an error instead of silently doing nothing. main(argv=None) is a small but useful habit: with no argument it reads the real command line, and tests can pass a list.
Step 4: help and errors come free
The help text is generated from the add_argument calls:
build_parser().print_help()usage: csvtool [-h] {stats,head} ...
Quick checks on CSV files.
positional arguments:
{stats,head}
stats summarise a numeric column
head print the first rows
options:
-h, --help show this help message and exitWhen the input is wrong, argparse prints the usage line and a specific message to standard error, then exits with status 2, the conventional code for bad command-line usage:
import contextlib
import io
err = io.StringIO()
with contextlib.redirect_stderr(err):
try:
main(["head", "sales.csv", "-n", "ten"])
except SystemExit as exc:
code = exc.code
print(err.getvalue().strip())
print("exit status:", code)usage: csvtool head [-h] [-n ROWS] path
csvtool head: error: argument -n/--rows: invalid int value: 'ten'
exit status: 2Shell scripts and schedulers read that exit status, so a failed run is visible without anyone reading the output. Return non-zero from main() for your own failures too, such as a missing column.
Validation beyond types
type=int accepts 0 and -3, which make no sense for a row count. A type can be any function that takes the string and returns a value; raising argparse.ArgumentTypeError inside it produces a clean error message in the same format as the built-in checks:
def positive_int(text: str) -> int:
value = int(text)
if value <= 0:
raise argparse.ArgumentTypeError(f"must be a positive integer, got {text}")
return value
strict = argparse.ArgumentParser(prog="csvtool head")
strict.add_argument("-n", "--rows", type=positive_int, default=5)
err = io.StringIO()
with contextlib.redirect_stderr(err):
try:
strict.parse_args(["-n", "0"])
except SystemExit:
pass
print(err.getvalue().strip().splitlines()[-1])csvtool head: error: argument -n/--rows: must be a positive integer, got 0Validation that involves several arguments together, such as "--start must be before --end", belongs after parsing. Check the values yourself and call parser.error("..."), which prints the usage line and your message and exits with status 2, exactly like the built-in errors.
Conventions users expect
- Results go to standard output, messages to standard error. Then
csvtool stats sales.csv > summary.txtcaptures only the result, while warnings still reach the terminal. - A --dry-run flag for anything that changes files, printing what would happen.
- Long, readable option names (
--rows), with short aliases (-n) only for the most used ones. - Exit status 0 for success and non-zero for failure, so the tool works in scripts and schedulers.
- Accept a dash (-) as a path meaning standard input, if the tool reads files, so it can sit in a pipeline.
Step 5: install it as a command
Typing python path/to/csvtool.py gets old. Put the code in a package, declare the command in pyproject.toml, and installing the package creates a csvtool command that calls main() (Python Packaging User Guide: writing pyproject.toml):
[project]
name = "csvtool"
version = "0.1.0"
requires-python = ">=3.9"
[project.scripts]
csvtool = "csvtool.cli:main"python -m pip install -e .
csvtool stats sales.csv --column amountThe -e (editable) install means code changes take effect without reinstalling. Because main() returns an integer, the generated command uses it as the exit status. The folder layout that makes this work, with the code under src/csvtool/, is covered in how to structure a Python project.
Testing the parser
Because main() takes a list, a test needs no subprocess:
def test_rows_must_be_an_integer():
try:
build_parser().parse_args(["head", "x.csv", "-n", "ten"])
except SystemExit as exc:
assert exc.code == 2
else:
raise AssertionError("expected argparse to reject -n ten")With pytest, pytest.raises(SystemExit) expresses the same check more tidily; see unit testing in Python with pytest.
Why not just read sys.argv?
How do I accept several values for one option?
Should options ever be required?
Can a default come from an environment variable?
When to reach for something else
argparse is in every Python installation and handles most tools well. Third-party libraries such as Click and Typer build parsers from decorators or type hints and add features such as colour output and shell completion. They are worth a look for large command suites. For a script that needs a handful of options, the standard library is enough, and it keeps the tool free of dependencies. When a command line grows long, fromfile_prefix_chars="@" lets users keep arguments in a file and pass @args.txt instead.
Command-line tools are a common first step from scripts towards applications; the modules and packages section of the Python Programming course covers the packaging side. For tools that talk to other programs over HTTP instead of the terminal, see building REST APIs with Python.
Sources
- argparse — Parser for command-line options, arguments and subcommands — Python documentation
- Argparse Tutorial — Python documentation
- Writing your pyproject.toml — Python Packaging User Guide
GloryTecks Team
Training & Placement Faculty
The GloryTecks faculty team mentors learners across Data Science, AI and Data Engineering, with a focus on real-time projects and placement readiness in Hyderabad.
Want to master Python?
Join GloryTecks’ expert-led Python Programming training in Hyderabad with real-time projects and 100% placement support.
Related articles
Learn Python from Scratch: A Complete 2026 Roadmap
How to learn Python from zero: install a current version (including the new Windows install manager), then work through seven stages, from values and loops to files, virtual environments, classes and tests, each ending with a small program you should be able to write on your own.
Python Interview Questions: Top 50 with Answers
Fifty conceptual Python interview questions with short, precise answers, grouped by topic: basics, types and mutability, data structures, functions and scope, OOP, iterators and decorators, errors and files, and internals, with runnable examples for the ones people most often get wrong.
Python for Data Science: The Essential Libraries
The Python data science stack explained by the job each library does: NumPy for arrays, pandas for tables, Matplotlib and seaborn for charts, SciPy and statsmodels for statistics, scikit-learn for models and Jupyter for exploration, with one runnable example that passes a real dataset through them.