DEV Community

Davis Mark
Davis Mark

Posted on

Build Practical Command-Line Tools with Python's argparse

The command line is where a lot of real work happens. If you have ever found yourself re-running the same Python script over and over, changing a filename or a threshold by editing the code each time, you are ready to learn argparse. It is Python's standard-library module for building friendly, professional command-line interfaces, and it ships with every Python installation, so there is nothing extra to install.

In this tutorial you will learn how to turn a plain script into a proper CLI tool with arguments, flags, defaults, subcommands, and helpful error messages. Everything is built with examples you can run immediately.

Why argparse instead of manual parsing?

Before argparse existed, many developers parsed arguments by hand:

import sys

def main():
    args = sys.argv[1:]
    if not args:
        print("usage: script.py FILENAME")
        sys.exit(1)
    filename = args[0]
    print(f"Processing {filename}")
Enter fullscreen mode Exit fullscreen mode

That works for exactly one case. The moment you need optional flags, typed values, a --version flag, or validation, hand-rolled parsing becomes fragile. argparse gives you all of this out of the box:

  • Automatic -h / --help text
  • Type conversion (int, float, file paths)
  • Default values
  • Choices and validation
  • Subcommands, like git commit and git push

Your first parser

Here is the simplest possible version:

import argparse

parser = argparse.ArgumentParser(description="Process a data file.")
parser.add_argument("filename", help="path to the input file")
args = parser.parse_args()

print(f"Input file: {args.filename}")
Enter fullscreen mode Exit fullscreen mode

Save it as demo.py and run it:

$ python demo.py sales.csv
Input file: sales.csv

$ python demo.py
usage: demo.py [-h] filename
demo.py: error: the following arguments are required: filename

$ python demo.py -h
usage: demo.py [-h] filename

Process a data file.

positional arguments:
  filename    path to the input file
Enter fullscreen mode Exit fullscreen mode

You got help text, a clear error, and a non-zero exit code for free. Notice the argument name becomes an attribute automatically: --filename becomes args.filename.

Positional vs. optional arguments

Arguments come in two flavors:

Kind Example Required? Access
Positional parser.add_argument("filename") Yes, unless nargs="?" args.filename
Optional flag parser.add_argument("--verbose") No args.verbose (True/False)
Optional with value parser.add_argument("--limit", type=int) No args.limit or default
Short flag parser.add_argument("-o", "--output") No args.output

A common pattern is an input file plus optional tuning flags:

parser.add_argument("input", help="input CSV file")
parser.add_argument("-o", "--output", help="write result to this file")
parser.add_argument("-n", "--limit", type=int, default=0,
                    help="only process the first N rows (0 = all)")
parser.add_argument("-v", "--verbose", action="store_true",
                    help="print extra detail while working")
Enter fullscreen mode Exit fullscreen mode

action="store_true" is how you make a simple on/off switch: if the user passes -v, args.verbose is True, otherwise False.

Type conversion and validation

By default argparse treats everything as a string. Passing type=int converts the value and rejects bad input automatically:

$ python demo.py data.csv --limit abc
usage: demo.py [-h] input [-o OUTPUT] [-n LIMIT] [-v]
demo.py: error: argument -n/--limit: invalid int value: 'abc'
Enter fullscreen mode Exit fullscreen mode

You can also restrict values with choices:

parser.add_argument("--mode", choices=["fast", "safe", "test"],
                    default="safe", help="processing mode")
Enter fullscreen mode Exit fullscreen mode

And enforce sensible ranges with a small helper function:

def positive_int(value):
    try:
        ivalue = int(value)
    except ValueError:
        raise argparse.ArgumentTypeError(f"{value!r} is not an integer")
    if ivalue <= 0:
        raise argparse.ArgumentTypeError(f"{value!r} must be positive")
    return ivalue

parser.add_argument("--workers", type=positive_int, default=1)
Enter fullscreen mode Exit fullscreen mode

When your validation raises ArgumentTypeError, argparse formats the message consistently with its own errors. Your users get one uniform style of feedback instead of a confusing traceback.

Reading files safely

A nice trick is type=argparse.FileType("r"). It opens the file for you and produces a clear error if the file does not exist:

parser.add_argument("input", type=argparse.FileType("r"),
                    help="input CSV file")
args = parser.parse_args()

for line in args.input:
    print(line.strip())
Enter fullscreen mode Exit fullscreen mode

Because FileType checks the file immediately, a typo in the filename fails fast with a clean message instead of halfway through your processing loop.

Using defaults so the tool works out of the box

Users appreciate a tool that runs with zero arguments. Give every optional flag a sensible default:

parser.add_argument("--output", default="report.txt")
parser.add_argument("--encoding", default="utf-8")
parser.add_argument("--max-errors", type=int, default=10)
Enter fullscreen mode Exit fullscreen mode

Now python tool.py data.csv is a complete, working command. Flags exist for the moments when the default is not what the user needs. This pattern matches how polished tools like git and curl behave: simple by default, configurable when required.

Subcommands for multi-purpose tools

Once a script grows past one job, split it into subcommands. This is the structure behind git commit, pip install, and npm run:

parser = argparse.ArgumentParser(description="CSV toolbox")
sub = parser.add_subparsers(dest="command", required=True,
                            help="available commands")

# validate subcommand
p_validate = sub.add_parser("validate", help="check a CSV for issues")
p_validate.add_argument("input", help="CSV file to check")

# summarize subcommand
p_summary = sub.add_parser("summary", help="print column statistics")
p_summary.add_argument("input", help="CSV file to analyze")
p_summary.add_argument("--column", default=None, help="column to focus on")

args = parser.parse_args()

if args.command == "validate":
    print(f"Validating {args.input}")
elif args.command == "summary":
    print(f"Summarizing {args.input}, column={args.column}")
Enter fullscreen mode Exit fullscreen mode

Each subcommand gets its own help screen:

$ python tool.py summary -h
usage: tool.py summary [-h] [--column COLUMN] input
...
Enter fullscreen mode Exit fullscreen mode

With required=True on add_subparsers, running tool.py with no command prints a useful error telling the user which commands exist.

A complete example: CSV column counter

Here is everything combined into one small, practical tool that counts rows per value in a chosen CSV column:

import argparse
import csv
from collections import Counter


def positive_int(value):
    ivalue = int(value)
    if ivalue <= 0:
        raise argparse.ArgumentTypeError("must be positive")
    return ivalue


def main():
    parser = argparse.ArgumentParser(
        description="Count occurrences of each value in a CSV column.")
    parser.add_argument("input", type=argparse.FileType("r"),
                        help="input CSV file")
    parser.add_argument("--column", required=True,
                        help="column name to count")
    parser.add_argument("--top", type=positive_int, default=10,
                        help="show only the top N values (default: 10)")
    parser.add_argument("-v", "--verbose", action="store_true",
                        help="print row counts while reading")
    args = parser.parse_args()

    reader = csv.DictReader(args.input)
    if args.column not in reader.fieldnames:
        parser.error(f"column {args.column!r} not found "
                     f"(available: {', '.join(reader.fieldnames)})")

    counts = Counter()
    total = 0
    for row in reader:
        counts[row[args.column]] += 1
        total += 1
        if args.verbose and total % 1000 == 0:
            print(f"... {total} rows read")

    print(f"\nTotal rows: {total}")
    print(f"Distinct values in {args.column!r}: {len(counts)}\n")
    for value, count in counts.most_common(args.top):
        print(f"{count:8d}  {value}")


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Run it against any CSV file:

$ python count_column.py orders.csv --column country --top 3

Total rows: 1250
Distinct values in 'country': 27

     412  Germany
     389  France
     205  Spain
Enter fullscreen mode Exit fullscreen mode

If the user names a column that does not exist, parser.error exits with a clear message and exit code 2, which is exactly what shell scripts expect when they test for failure.

Testing a parser-based tool

Because parsing is separated from processing, you can test the pieces. argparse even lets you feed in fake arguments instead of the real sys.argv:

def parse_args(argv=None):
    parser = argparse.ArgumentParser()
    parser.add_argument("input")
    parser.add_argument("--limit", type=int, default=0)
    return parser.parse_args(argv)


def test_parse_args():
    args = parse_args(["data.csv", "--limit", "5"])
    assert args.input == "data.csv"
    assert args.limit == 5


def test_default_limit():
    args = parse_args(["data.csv"])
    assert args.limit == 0
Enter fullscreen mode Exit fullscreen mode

Keeping parse_args separate from main is the single most useful habit for testable command-line tools. You can now exercise every flag combination without ever spawning a subprocess.

Practical tips summary

  • Use type=int (or a small validator function) instead of casting strings later.
  • Give every optional argument a sensible default so the tool runs with a bare command.
  • Separate parse_args(argv=None) from the processing logic so it is testable.
  • Use argparse.FileType for file arguments to get clean errors early.
  • Use subcommands once a script handles more than one job.
  • Check choices and parser.error() for validation that reads naturally.
  • The -h flag is automatic; write help strings that complete the sentence "the value for this argument is ...".

Next steps

Try converting one of your existing scripts: take the values you currently edit at the top of the file, and promote them to command-line arguments with defaults. You will immediately get a tool you can hand to a colleague or call from a shell script, and you will never edit a filename inside the code again. argparse is one of those standard-library modules that quietly makes everything around it more professional, and it is a great foundation before reaching for heavier frameworks when your interface eventually grows beyond what a single parser can express.

Top comments (0)