Command-line Apps with argparse

Python · Lesson 50 of 56 8 min read Updated

Many useful programs have no windows or buttons. You run them in a terminal and type instructions after the name: git commit -m "fix", pip install requests, python -m venv .venv. The words after the program name are called command-line arguments, and a program that reads them is a command-line app (CLI app). They are quick to build, easy to automate, and perfect for scripts that rename files, process CSV reports or back up folders.

In this lesson you will read arguments by hand with sys.argv, then use the standard argparse module to build tools with options, automatic help, clear error messages and sub-commands.

The raw way: sys.argv

Python puts the words typed on the command line into the list sys.argv. Item 0 is the script name, and the rest are the arguments, always as strings.

Python
import sys

print(sys.argv)
name = sys.argv[1]
times = int(sys.argv[2])
for _ in range(times):
    print(f"Namaste, {name}!")
Terminal
python greet.py Priya 2
Output
['greet.py', 'Priya', '2']
Namaste, Priya!
Namaste, Priya!

This works until someone forgets an argument (IndexError: list index out of range), types two instead of 2 (ValueError), or asks how to use the program. Handling all of that by hand takes a lot of code. argparse does it for you.

Your first ArgumentParser

You describe the arguments your program accepts, and argparse reads sys.argv, converts the values, and returns them as attributes of a Namespace object.

parse_args() normally reads the real command line. You can also pass it a list of words, which is how you test a parser, and how every runnable example in this lesson works in the browser.

PythonRun it yourself
import argparse

parser = argparse.ArgumentParser(prog="fare", description="Work out an auto-rickshaw fare.")
parser.add_argument("km", type=float, help="distance in kilometres")
parser.add_argument("--night", action="store_true", help="add the night charge")

args = parser.parse_args(["7.5"])
print(args)
print(args.km * 15, args.night)

args = parser.parse_args(["12", "--night"])
fare = args.km * 15 * (1.5 if args.night else 1)
print(f"Fare: ₹{fare:.0f}")
Output
Namespace(km=7.5, night=False)
112.5 False
Fare: ₹270
  • "km" has no dashes, so it is a positional argument: it is required, and its meaning comes from its position.
  • type=float converts the text "7.5" to a number. Any function that takes a string works as a type.
  • "--night" starts with dashes, so it is an optional argument. action="store_true" makes it a flag: True if typed, False if not.

Options, defaults and choices

Most real tools combine several kinds of options. These are the settings you will use most:

SettingEffectExample
default=Value when the option is not givendefault=1
choices=Only these values are allowedchoices=["Delhi", "Agra"]
nargs="+"One or more values, collected in a list--names Aarav Isha
nargs="?"An optional positional argument
required=TrueThe option must be given (use sparingly)
"-n", "--seats"A short and a long name for one option-n 2 or --seats 2
dest=A different attribute namedest="count"

A dash inside an option name becomes an underscore in the Namespace, so --from-city is read as args.from_city.

PythonRun it yourself
import argparse

parser = argparse.ArgumentParser(prog="bus", description="Book bus seats from Jaipur.")
parser.add_argument("city", choices=["Delhi", "Agra", "Udaipur"], help="where you are going")
parser.add_argument("-n", "--seats", type=int, default=1, help="number of seats (default: 1)")
parser.add_argument("--names", nargs="+", default=[], help="passenger names")
parser.add_argument("--ac", action="store_true", help="book an AC bus")

print(parser.parse_args(["Agra"]))
print(parser.parse_args(["Delhi", "-n", "2", "--names", "Rohan", "Meera", "--ac"]))
print()
parser.print_help()
Output
Namespace(city='Agra', seats=1, names=[], ac=False)
Namespace(city='Delhi', seats=2, names=['Rohan', 'Meera'], ac=True)

usage: bus [-h] [-n SEATS] [--names NAMES [NAMES ...]] [--ac]
           {Delhi,Agra,Udaipur}

Book bus seats from Jaipur.

positional arguments:
  {Delhi,Agra,Udaipur}  where you are going

options:
  -h, --help            show this help message and exit
  -n, --seats SEATS     number of seats (default: 1)
  --names NAMES [NAMES ...]
                        passenger names
  --ac                  book an AC bus

You never wrote that help text. argparse built it from your help= strings, and users see it by typing --help or -h. Options can appear in any order on the command line.

Helpful errors and custom types

When the input is wrong, argparse prints a short usage line and a clear error to the error stream, then stops the program with exit code 2. An exit code is a number a program returns to the terminal: 0 means success and anything else means failure, which scripts and other tools can check. In the browser editor we catch the SystemExit exception so the example keeps running.

For rules that type=int cannot express, write your own type function. Raise argparse.ArgumentTypeError and argparse turns your message into a proper error.

PythonRun it yourself
import argparse

def positive_int(text):
    value = int(text)
    if value < 1:
        raise argparse.ArgumentTypeError(f"must be at least 1, got {value}")
    return value

parser = argparse.ArgumentParser(prog="bus")
parser.add_argument("city", choices=["Delhi", "Agra", "Udaipur"])
parser.add_argument("-n", "--seats", type=positive_int, default=1)

for words in (["Pune"], ["Agra", "--seats", "0"], ["Agra", "--seats", "two"]):
    try:
        parser.parse_args(words)
    except SystemExit as stop:
        print("Exit code:", stop.code)
Output
usage: bus [-h] [-n SEATS] {Delhi,Agra,Udaipur}
bus: error: argument city: invalid choice: 'Pune' (choose from 'Delhi', 'Agra', 'Udaipur')
Exit code: 2
usage: bus [-h] [-n SEATS] {Delhi,Agra,Udaipur}
bus: error: argument -n/--seats: must be at least 1, got 0
Exit code: 2
usage: bus [-h] [-n SEATS] {Delhi,Agra,Udaipur}
bus: error: argument -n/--seats: invalid positive_int value: 'two'
Exit code: 2

If your type function raises ValueError (as int("two") does), argparse reports "invalid ... value" using the function's name, so give type functions clear names.

Sub-commands

Bigger tools have sub-commands, each with its own arguments, like git add and git commit. Create them with add_subparsers(). A neat pattern is set_defaults(func=...): each sub-command stores the function that should handle it, and the main code simply calls args.func(args).

PythonRun it yourself
import argparse

notes = ["Buy chalk for class 7", "Call Farhan about the fest"]

def cmd_list(args):
    for number, note in enumerate(notes, start=1):
        print(number, note)

def cmd_add(args):
    text = " ".join(args.words)
    notes.append(text.upper() if args.urgent else text)
    print("Added note", len(notes))

parser = argparse.ArgumentParser(prog="notes")
commands = parser.add_subparsers(dest="command", required=True)

list_cmd = commands.add_parser("list", help="show all notes")
list_cmd.set_defaults(func=cmd_list)

add_cmd = commands.add_parser("add", help="add a note")
add_cmd.add_argument("words", nargs="+")
add_cmd.add_argument("--urgent", action="store_true")
add_cmd.set_defaults(func=cmd_add)

for words in (["add", "Print", "exam", "timetable", "--urgent"], ["list"]):
    args = parser.parse_args(words)
    args.func(args)
Output
Added note 3
1 Buy chalk for class 7
2 Call Farhan about the fest
3 PRINT EXAM TIMETABLE

required=True on the sub-parsers means that running notes with no command shows an error instead of doing nothing. The Project: Expense Tracker builds a complete tool with this structure.

A complete script

Real CLI scripts usually follow one shape: a main(argv=None) function that parses arguments and returns an exit code. Passing None makes parse_args() read the real command line, while tests can pass a list.

Python
import argparse

def build_parser():
    parser = argparse.ArgumentParser(prog="bus", description="Book bus seats from Jaipur.")
    parser.add_argument("city", choices=["Delhi", "Agra", "Udaipur"], help="where you are going")
    parser.add_argument("-n", "--seats", type=int, default=1, help="number of seats (default: 1)")
    parser.add_argument("--ac", action="store_true", help="book an AC bus")
    return parser

def main(argv=None):
    args = build_parser().parse_args(argv)
    fare = {"Delhi": 650, "Agra": 480, "Udaipur": 720}[args.city]
    if args.ac:
        fare += 250
    print(f"{args.seats} seat(s) to {args.city}: ₹{fare * args.seats}")
    return 0

if __name__ == "__main__":
    raise SystemExit(main())
Terminal
python bus.py Udaipur -n 2 --ac
Output
2 seat(s) to Udaipur: ₹1940

raise SystemExit(main()) passes the return value to the terminal as the exit code. When you later package the tool (see Packaging with pyproject.toml), the same main() becomes a command users can run without typing python.

Third-party options: click and typer

argparse is always available, which makes it the safe default. Two popular packages from PyPI offer a different style:

  • click builds commands with decorators such as @click.command() and @click.option().
  • typer reads your function's type hints and builds the whole interface from them.
Python
import typer

def main(city: str, seats: int = 1, ac: bool = False):
    """Book bus seats to CITY."""
    kind = "AC" if ac else "non-AC"
    print(f"Booked {seats} {kind} seat(s) to {city}")

if __name__ == "__main__":
    typer.run(main)
Terminal
python book.py Jaipur --seats 2 --ac
Output
Booked 2 AC seat(s) to Jaipur

Install it with python -m pip install typer inside a virtual environment. For a small script, argparse is plenty; for a large tool with many commands, click or typer can save typing.

Common mistakes

  • Forgetting that arguments are strings. Without type=int, args.seats * 2 repeats the text: "22".
  • Using type=bool. bool("False") is True. Use action="store_true" for flags.
  • Making options required. If a value is always needed, make it positional instead of required=True.
  • Testing by typing in the terminal every time. Pass a list to parse_args() in tests.
  • Calling parse_args() at import time. Keep it inside main() so other code can import your module without side effects.

Key points

  • sys.argv holds the raw words, but argparse adds conversion, validation, errors and --help.
  • Positional arguments are required; names starting with -- are optional.
  • type=, default=, choices=, nargs= and action="store_true" cover most needs.
  • Bad input prints a usage line and an error, then exits with code 2; custom types raise ArgumentTypeError.
  • add_subparsers() with set_defaults(func=...) builds git-style sub-commands.
  • Wrap everything in main(argv=None); consider click or typer for large tools.

Exercise

Write a parser for a tool called marks that takes one or more whole-number marks as positional arguments, an optional --pass-mark (whole number, default 40) and a --show-failed flag. Test it with parse_args(["78", "35", "91", "22", "--show-failed"]). Print the average and the number of passes, and when the flag is on, also print the failed marks.

Show solution
Python
import argparse

parser = argparse.ArgumentParser(prog="marks", description="Summarise exam marks.")
parser.add_argument("marks", type=int, nargs="+", help="marks to summarise")
parser.add_argument("--pass-mark", type=int, default=40)
parser.add_argument("--show-failed", action="store_true")

args = parser.parse_args(["78", "35", "91", "22", "--show-failed"])
passed = [m for m in args.marks if m >= args.pass_mark]
print(f"Average: {sum(args.marks) / len(args.marks):.1f}")
print("Passed:", len(passed))
if args.show_failed:
    print("Failed:", [m for m in args.marks if m < args.pass_mark])
Output
Average: 56.5
Passed: 2
Failed: [35, 22]

nargs="+" collects all the marks into a list, and type=int converts each one. The option --pass-mark is read as args.pass_mark, with an underscore.

Quick Quiz

1. What is in sys.argv[0]?

2. How do you make an option that is True when typed and False otherwise?

3. What does argparse do when the user gives an invalid choice?

4. How is the option --pass-mark read from the Namespace?

Learn Python with a mentor

Free lessons get you started. Our Python Programming training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call