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

6 May 2026 7 min read

Last updated: 24 September 2026

Share:

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):

python
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)
text
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:

python
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"]))
text
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:

python
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"])
text
amount: n=3 mean=86.67 max=120.00
region,amount
north,120
south,80

required=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:

python
build_parser().print_help()
text
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 exit

When 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:

python
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)
text
usage: csvtool head [-h] [-n ROWS] path
csvtool head: error: argument -n/--rows: invalid int value: 'ten'
exit status: 2

Shell 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:

python
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])
text
csvtool head: error: argument -n/--rows: must be a positive integer, got 0

Validation 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.txt captures 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):

toml
[project]
name = "csvtool"
version = "0.1.0"
requires-python = ">=3.9"

[project.scripts]
csvtool = "csvtool.cli:main"
text
python -m pip install -e .
csvtool stats sales.csv --column amount

The -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:

python
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?

sys.argv is a plain list of strings. It is fine for a throwaway script with one argument, but as soon as you need options, defaults, type conversion or help text, you would be rewriting argparse by hand, usually with fewer checks.

How do I accept several values for one option?

Use nargs='+' to take one or more values after a single flag, as in --columns amount qty, or action='append' to let the flag repeat, as in --tag a --tag b. Both give you a list.

Should options ever be required?

Try to avoid it. A value the tool cannot work without is clearer as a positional argument. If an option really is mandatory, required=True makes argparse enforce it.

Can a default come from an environment variable?

Yes: default=os.environ.get("CSVTOOL_DELIMITER", ",") lets users set a preference once in their shell, while an explicit flag on the command line still wins.

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

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.

Explore course