Skip to content
aviral gupta

// I4.2 · ~35 min · Intermediate

Command-line programs with argparse and sys

After this lesson you can turn a script into a command-line program: it reads arguments and options with argparse, prints errors to stderr and ends with the right exit status.

Lesson 2 of 6 in I4 The standard library for real programs

You will be able to

  • Define positional arguments, options with a type and a default, and on/off flags with argparse
  • Parse sys.argv or a list you pass in, and read the results by attribute name
  • Report errors on stderr and end with exit status 0, 1 or 2 via sys.exit
  1. Warm-up · Activity 1 of 7

    Warm-up from the modules lesson: this file is run with python main.py. What does it print?

    print(__name__)
  2. Predict · Activity 2 of 7

    Predict before you read on. The list stands for the command line greet.py Ada --times 3. What does this print?

    import argparse
    
    parser = argparse.ArgumentParser()
    parser.add_argument("name")
    parser.add_argument("--times", type=int, default=1)
    args = parser.parse_args(["Ada", "--times", "3"])
    print(args.name * args.times)
  3. Practice · Activity 3 of 7

    Fill in the action so that --verbose is an on/off flag: True when given, False when left out.

    parser.add_argument("--verbose", action="____")
    action="")
  4. Practice · Activity 4 of 7

    sys.exit can take a message. Which exit status does the shell see when this program ends?

    import sys
    
    sys.exit("disk full")
  5. Practice · Activity 5 of 7

    Match each add_argument call to what it means.

  6. Brain teaser · Activity 6 of 7

    Brain teaser. Someone wanted a yes/no option and wrote type=bool. What does this print?

    import argparse
    
    parser = argparse.ArgumentParser()
    parser.add_argument("--debug", type=bool)
    args = parser.parse_args(["--debug", "False"])
    print(args.debug)
  7. Apply · Activity 7 of 7

    Mini-task, on your own computer. Write temp.py: it takes a Celsius temperature as a positional argument (a float) and prints it in Fahrenheit, like 212.0 F. With --kelvin it prints Kelvin instead, like 273.15 K. Below -273.15 it prints an error to stderr and ends with exit status 1. Try python temp.py 100, python temp.py 0 --kelvin and python temp.py abc.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

A word counter for the command line

wordcount counts the words of a text, with an option --min-length and a flag -u. So that you can see several command lines at once, demo() runs main with argument lists and shows the exit status; stderr is sent to the output too. On your computer, replace the demo calls with sys.exit(main()) and run python main.py "the cat sat" --min-length 3.

main.py

import argparse
import shlex
import sys
from contextlib import redirect_stderr


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(prog="wordcount", description="Count the words in a text.")
    parser.add_argument("text", help="the text to count")
    parser.add_argument("--min-length", type=int, default=1, help="skip shorter words")
    parser.add_argument("-u", "--upper", action="store_true", help="print the words in upper case")
    args = parser.parse_args(argv)  # None: read sys.argv[1:]

    words = [word for word in args.text.split() if len(word) >= args.min_length]
    if not words:
        print("wordcount: no words left", file=sys.stderr)
        return 1
    shown = " ".join(words).upper() if args.upper else " ".join(words)
    print(f"{len(words)} words: {shown}")
    return 0


def demo(argv: list[str]) -> None:
    """Runs main as if typed in a terminal; stderr shows up in the output too."""
    print(f"$ python main.py {shlex.join(argv)}".rstrip())
    with redirect_stderr(sys.stdout):
        try:
            status = main(argv)
        except SystemExit as stop:  # argparse exits on --help and on bad input
            status = stop.code if isinstance(stop.code, int) else 1
    print("exit status", status)


if __name__ == "__main__":
    # On your computer, replace these demos with: sys.exit(main())
    demo(["the cat sat on the mat", "--min-length", "3"])
    demo(["-u", "hello world"])
    demo(["a b", "--min-length", "5"])
    demo(["hello", "--min-length", "three"])
    demo([])
    demo(["--help"])

Run it with

python main.py

Output

$ python main.py 'the cat sat on the mat' --min-length 3
5 words: the cat sat the mat
exit status 0
$ python main.py -u 'hello world'
2 words: HELLO WORLD
exit status 0
$ python main.py 'a b' --min-length 5
wordcount: no words left
exit status 1
$ python main.py hello --min-length three
usage: wordcount [-h] [--min-length MIN_LENGTH] [-u] text
wordcount: error: argument --min-length: invalid int value: 'three'
exit status 2
$ python main.py
usage: wordcount [-h] [--min-length MIN_LENGTH] [-u] text
wordcount: error: the following arguments are required: text
exit status 2
$ python main.py --help
usage: wordcount [-h] [--min-length MIN_LENGTH] [-u] text

Count the words in a text.

positional arguments:
  text                  the text to count

options:
  -h, --help            show this help message and exit
  --min-length MIN_LENGTH
                        skip shorter words
  -u, --upper           print the words in upper case
exit status 0
  • --min-length is read as args.min_length, and type=int made it a number.
  • Our own error returns 1; the errors argparse finds end with 2 before main goes on.
  • argparse wrote the usage line and the whole --help text from the add_argument calls.
  • prog="wordcount" fixes the name in messages; without it argparse uses the script name.

Exercises

Exercise 1 of 2

A greeting tool

Complete main(argv) in greet.py (here main.py). It takes a name, an option --times (an int, default 1) and a flag --shout. It prints Hello, <name>! once per time, in capitals with --shout, and returns 0. A --times that is not a number must end with argparse's exit status 2. Run the tests, then try python main.py Ada --times 3 --shout on your computer (python3 on macOS and Linux).

This exercise needs Python on your computer (the browser version cannot run it). The files and commands are below.

Hints
  1. Hint 1

    parser.add_argument("--times", type=int, default=1) makes args.times an int that is 1 when left out.

  2. Hint 2

    action="store_true" makes --shout a flag; test it with if args.shout:.

  3. Hint 3

    Build the greeting once, call .upper() when shouting, and print it in for _ in range(args.times):.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import argparse
import sys


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(prog="greet")
    parser.add_argument("name")
    parser.add_argument("--times", type=int, default=1)
    parser.add_argument("--shout", action="store_true")
    args = parser.parse_args(argv)
    greeting = f"Hello, {args.name}!"
    if args.shout:
        greeting = greeting.upper()
    for _ in range(args.times):
        print(greeting)
    return 0


if __name__ == "__main__":
    sys.exit(main())
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

import argparse
import sys


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(prog="greet")
    parser.add_argument("name")
    # Add --times (an int, default 1) and the flag --shout.
    args = parser.parse_args(argv)
    print(f"Hello, {args.name}!")
    return 0


if __name__ == "__main__":
    sys.exit(main())

test_main.py

import contextlib
import io

from main import main


def run(argv):
    out = io.StringIO()
    err = io.StringIO()
    with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
        try:
            status = main(argv)
        except SystemExit as stop:
            status = stop.code
    return status, out.getvalue().splitlines(), err.getvalue()


def test_default():
    """Ada alone prints one greeting and returns 0"""
    status, lines, err = run(["Ada"])
    assert (status, lines) == (0, ["Hello, Ada!"]), f"main(['Ada']) returned {status!r} and printed {lines!r}; stderr: {err!r}"


def test_times():
    """--times 3 prints the greeting three times"""
    status, lines, err = run(["Ada", "--times", "3"])
    assert lines == ["Hello, Ada!"] * 3, f"with --times 3 the program printed {lines!r}; stderr: {err!r}"


def test_shout():
    """--shout prints in capitals, before or after the name"""
    status, lines, err = run(["--shout", "Bo"])
    assert lines == ["HELLO, BO!"], f"with --shout the program printed {lines!r}; stderr: {err!r}"


def test_bad_times():
    """--times many ends with exit status 2"""
    status, lines, err = run(["Ada", "--times", "many"])
    assert status == 2, f"with --times many the status was {status!r}, expected 2 (let type=int reject it)"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Exercise 2 of 2

Average, with exit statuses

main.py averages the numbers on its command line, with --max (default 100). Fix it: when a number is above the maximum, print average: 500.0 is above the maximum 100.0 (with the real values) to stderr, print nothing to stdout and return 1. Otherwise print the average with two decimals and return 0. Run as a program, the status must reach the shell: end with sys.exit(main()).

This exercise needs Python on your computer (the browser version cannot run it). The files and commands are below.

Hints
  1. Hint 1

    print(..., file=sys.stderr) sends a line to stderr instead of stdout.

  2. Hint 2

    Return 1 right after printing the error, so the average is never printed.

  3. Hint 3

    main() returns the status, but only sys.exit(main()) hands it to the shell.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import argparse
import sys


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(prog="average")
    parser.add_argument("numbers", nargs="+", type=float)
    parser.add_argument("--max", type=float, default=100.0)
    args = parser.parse_args(argv)
    for number in args.numbers:
        if number > args.max:
            print(f"average: {number} is above the maximum {args.max}", file=sys.stderr)
            return 1
    print(f"{sum(args.numbers) / len(args.numbers):.2f}")
    return 0


if __name__ == "__main__":
    sys.exit(main())
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

import argparse
import sys


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(prog="average")
    parser.add_argument("numbers", nargs="+", type=float)
    parser.add_argument("--max", type=float, default=100.0)
    args = parser.parse_args(argv)
    for number in args.numbers:
        if number > args.max:
            print(f"average: {number} is above the maximum {args.max}")
    print(f"{sum(args.numbers) / len(args.numbers):.2f}")
    return 0


if __name__ == "__main__":
    main()

test_main.py

import contextlib
import io
import sys

from learnrun import run_main
from main import main


def run(argv):
    out = io.StringIO()
    err = io.StringIO()
    with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
        try:
            status = main(argv)
        except SystemExit as stop:
            status = stop.code
    return status, out.getvalue(), err.getvalue()


def test_average():
    """2, 4 and 9 give 5.00 and status 0"""
    status, out, err = run(["2", "4", "9"])
    assert (status, out.strip()) == (0, "5.00"), f"main returned {status!r} and printed {out!r}"


def test_above_max():
    """500 is reported on stderr, with status 1 and nothing on stdout"""
    status, out, err = run(["5", "500"])
    assert status == 1, f"main returned {status!r}, expected 1"
    assert out == "", f"stdout should be empty, but it holds {out!r}"
    assert err.strip() == "average: 500.0 is above the maximum 100.0", f"stderr holds {err!r}"


def test_custom_max():
    """--max 10 rejects 11"""
    status, out, err = run(["--max", "10", "5", "11"])
    assert status == 1, f"with --max 10 and 11, main returned {status!r}, expected 1"


def test_not_a_number():
    """A word instead of a number ends with status 2"""
    status, out, err = run(["five"])
    assert status == 2, f"for five, the status was {status!r}, expected 2"


def test_status_reaches_the_shell():
    """Run as a program, python main.py 5 500 exits with status 1"""
    saved = sys.argv
    sys.argv = ["main.py", "5", "500"]
    code = 0
    try:
        with contextlib.redirect_stderr(io.StringIO()):
            run_main()
    except SystemExit as stop:
        code = stop.code
    finally:
        sys.argv = saved
    assert code == 1, f"the program ended with status {code!r}, expected 1: call sys.exit(main())"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Common mistakes

Forgetting type=int

import argparse

parser = argparse.ArgumentParser()
parser.add_argument("count")
args = parser.parse_args(["3"])
print(args.count + 1)

What Python prints

TypeError: can only concatenate str (not "int") to str

Why, and the fix

Every command-line argument arrives as a string, and argparse keeps it one unless you give a type. Write add_argument("count", type=int). Then argparse converts it, and rejects a value like three with a clear error and exit status 2.

Reading an option with a dash in its name

import argparse

parser = argparse.ArgumentParser(prog="wordcount")
parser.add_argument("--min-length", type=int, default=1)
args = parser.parse_args(["--min-length", "3"])
print(args.min-length)

What Python prints

AttributeError: 'Namespace' object has no attribute 'min'

Why, and the fix

args.min-length is read as args.min minus length. argparse turns the dashes inside an option name into underscores, so the value is args.min_length.

Leaving out a required argument

import argparse

parser = argparse.ArgumentParser(prog="greet")
parser.add_argument("name")
args = parser.parse_args([])
print("Hello,", args.name)

What Python prints

greet: error: the following arguments are required: name

Why, and the fix

A positional argument must be given. argparse prints the usage and this error to stderr and exits with status 2, so print is never reached. To make it optional, turn it into an option with a default, such as add_argument("--name", default="world").

Python in the browser: Pyodide 314.0.7, MPL-2.0. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

From sys.argv to argparse

python greet.py Ada --times 3 puts the words of the command in sys.argv: ["greet.py", "Ada", "--times", "3"]. argv[0] is the script, and every item is a string. argparse reads that list for you: add_argument("name") is a positional argument, which is required. add_argument("--times", type=int, default=1) is an option, converted by type and set to default when it is left out. action="store_true" makes a flag: True when given, else False. nargs="+" collects one or more values into a list. argparse also writes --help for you.

A testable main(argv)

parse_args(argv) parses the list you pass; with None it reads sys.argv[1:]. So write def main(argv: list[str] | None = None) -> int, and end the file with if __name__ == "__main__": sys.exit(main()). In a terminal, main reads the real command line; in a test, main(["Ada", "--times", "3"]) runs it with a list. The results are attributes: args.name, args.times. Dashes in an option become underscores, so --min-length is read as args.min_length.

Exit statuses and stderr

When a program ends, the shell gets its exit status: 0 means success, anything else failure. Unix programs use 2 for a bad command line and 1 for other errors. argparse already does this: for a missing or invalid argument it prints the usage and an error to stderr and exits with 2. For your own errors, print the message with print(..., file=sys.stderr) and return 1 from main. sys.exit(status) raises SystemExit. Errors on stderr stay visible even when the output is saved with > out.txt.

Sources

Last reviewed September 29, 2026