pyguides

Building CLI Tools with argparse

Building CLI tools in Python doesn’t require a third-party library. If you’ve ever used a command-line tool, you’ve probably passed arguments like --verbose or -f filename. These are handled by the argparse module, part of Python’s standard library. It’s the standard way to build CLIs that feel professional and behave like Unix tools.

Key takeaways: argparse transforms your Python script into a full-featured command-line tool with automatic help text, type validation, and subcommand support. Call ArgumentParser() to create a parser, add arguments with add_argument(), then call parse_args() to access parsed values as attributes on the returned namespace. For boolean flags use action="store_true", for subcommands use add_subparsers(), and for input validation use type=int or the choices parameter. The default parameter sets fallback values when users omit optional arguments, while argument groups and the description/epilog parameters keep help output organized and professional.

Why should you use argparse?

Before argparse, developers used sys.argv directly. It works, but you end up parsing strings manually, handling errors yourself, and producing unhelpful error messages. Argparse gives you:

  • Automatic help text generation
  • Type conversion and validation
  • Positional and optional arguments
  • Subcommands (like git commit vs git push)

It’s been in the standard library since Python 3.2, so there’s no excuse not to use it.

Your first argparse script

The simplest possible argparse script creates a parser and calls parse_args(). At this point there are no user-defined arguments, but argparse still provides a --help option automatically. This minimal scaffolding is enough to verify your argparse import works and to see the default output format that argparse generates:

import argparse

parser = argparse.ArgumentParser()
parser.parse_args()

Running the script with --help reveals the usage line and the built-in help option. This output won’t do anything useful yet, but it confirms argparse is working and gives you a template to build on. Notice that argparse formats the help output with consistent indentation and automatically wraps option descriptions:

$ python script.py --help
usage: script.py [-h]

options:
  -h, --help  show this help message and exit

To make the output more descriptive, pass a description string to ArgumentParser. The description appears at the top of the help text and tells users what your script does. Beyond that, the real power comes from adding arguments that your script will actually use. Positional arguments are the simplest type to add, and they are the first building block of any argparse-based CLI.

How do positional arguments work in argparse?

Positional arguments are required and identified by their position on the command line. Think of python script.py filename where filename is positional. When you add a positional argument with add_argument("filename"), argparse expects exactly one value in that position and stores it under the attribute name matching the argument string. The help parameter on each argument feeds directly into the auto-generated help text:

import argparse

parser = argparse.ArgumentParser(description="Read a file")
parser.add_argument("filename", help="Name of the file to read")
args = parser.parse_args()

print(f"Reading from: {args.filename}")

When you run the script, argparse parses the command line, maps myfile.txt to the filename attribute, and your code accesses it through args.filename. If you omit the required argument, argparse prints an error and exits. This built-in validation saves you from writing manual argument-checking logic:

$ python script.py myfile.txt
Reading from: myfile.txt

You can define multiple positional arguments in a single parser, and argparse handles them in order. Adding type=int to an argument tells argparse to convert the string from the command line into an integer before your code ever sees it. This combination of ordered parsing and automatic type conversion makes it straightforward to build small mathematical utilities that take numeric inputs directly from the CLI:

import argparse

parser = argparse.ArgumentParser()
parser.add_argument("x", type=int, help="First number")
parser.add_argument("y", type=int, help="Second number")
args = parser.parse_args()

print(f"Sum: {args.x + args.y}")

The type parameter accepts any callable that takes a string and returns a value. This design choice means argparse integrates cleanly with Python’s entire type system. If the conversion fails, argparse catches the exception and produces a user-friendly error message that includes the argument name and the problematic input. This means your code receives properly typed values without needing to handle conversion errors manually:

$ python script.py 5 3
Sum: 8

Positional arguments work well when the order of inputs is obvious and every input is required. But many CLI tools need optional parameters that users can skip or supply in any order. For that, argparse provides optional arguments, which give callers far more flexibility.

What are optional arguments and when should you use them?

Optional arguments start with - or -- and are, well, optional. The double-dash prefix is the Python convention for long options, making them self-documenting on the command line. When a user supplies an optional argument, the value is available through args.name (with hyphens converted to underscores). When the user omits it, the attribute defaults to None, so your code must check for that case before using the value:

import argparse

parser = argparse.ArgumentParser(description="Print a message")
parser.add_argument("--name", help="Name to greet")
args = parser.parse_args()

if args.name:
    print(f"Hello, {args.name}!")
else:
    print("Hello, World!")

Running the script without --name produces the fallback greeting, while passing --name Alice overrides it. When an optional argument is omitted, argparse stores None as its value, so your program can detect which options were supplied and which were not. This branching pattern, where a single argument controls whether specialized behavior kicks in, is the foundation of most CLI tool design:

$ python script.py
Hello, World!

$ python script.py --name Alice
Hello, Alice!

For frequently used options, argparse supports combining a short flag (single dash, single letter) with a long flag (double dash, descriptive name). The short form saves typing for power users, while the long form keeps scripts readable. Both forms set the same attribute, so your code accesses the value through a single args attribute:

parser.add_argument("-v", "--verbose", help="Enable verbose output")

With this definition, -v and --verbose are interchangeable. Both flags set the same attribute on the parsed namespace, which means the script behaves identically whether the user types the short or long form. You can verify this by running the script with each flag and observing that argparse accepts both without complaint:

$ python script.py -v
$ python script.py --verbose

The terminal output above shows both flag forms being accepted. The short form is especially useful for flags that get used repeatedly in shell pipelines, where brevity matters more than explicitness. The long form, in contrast, makes scripts self-documenting for anyone reading the invocation later.

How do flags and boolean options work in argparse?

For simple on/off toggles, use action="store_true". This sets the argument to True when the flag is present and False when it’s absent, without requiring the user to write --flag true or --flag false. The action mechanism keeps boolean flags clean and prevents the common mistake of users typing --flag false and being confused when it’s treated as a truthy string:

import argparse

parser = argparse.ArgumentParser()
parser.add_argument("-q", "--quiet", action="store_true", help="Suppress output")
args = parser.parse_args()

if args.quiet:
    print("Quiet mode on")
else:
    print("Normal mode")

The store_true action is arguably the most common argparse pattern you’ll encounter. It’s used in virtually every CLI tool that supports a verbose flag, a dry-run flag, or any other binary toggle. When the flag is present, argparse sets the attribute to True; when absent, it stays False. This clean true/false behavior eliminates the need for users to supply an explicit boolean value on the command line:

$ python script.py
Normal mode

$ python script.py -q
Quiet mode on

For the inverse behavior, use action="store_false", which sets the attribute to False when the flag is present. This is useful for flags like --no-cache where the default is True and the user wants to disable a feature. The action mechanism also includes store_const for setting a fixed value and append for collecting multiple occurrences of the same flag into a list. Understanding these actions is key because they cover the vast majority of real-world CLI flag requirements without needing custom code.

How does argparse handle type conversion?

Argparse can automatically convert argument strings to the types your code actually needs. By specifying type on add_argument, you tell argparse which callable to use for conversion. The conversion runs before your code accesses args, so you never have to manually cast values. If the conversion fails, argparse produces a clean error message naming the problematic argument:

import argparse

parser = argparse.ArgumentParser()
parser.add_argument("count", type=int, help="Number of times to repeat")
parser.add_argument("rate", type=float, help="Repeat rate per second")
args = parser.parse_args()

print(f"Will repeat {args.count} times at {args.rate} per second")

The type parameter works with any callable that accepts a single string argument, which means you can pass custom functions for domain-specific validation. For example, you could write a function that parses a date string, validates an IP address, or normalizes a file path:

$ python script.py 10 2.5
Will repeat 10 times at 2.5 per second

Beyond built-in types like int and float, you can use type=bool (though be aware that any non-empty string is truthy), type=pathlib.Path for filesystem paths, or even type=json.loads for JSON-encoded arguments. The automatic error message when conversion fails is one of argparse’s most underappreciated features, saving you from writing repetitive try/except blocks just to handle bad user input.

Choices

When an argument should only accept a specific set of values, use the choices parameter. Argparse validates the input against the allowed set and rejects anything outside it with an informative error message that lists the valid options. This is far cleaner than accepting a free-form string and then validating it manually with an if/elif chain:

import argparse

parser = argparse.ArgumentParser()
parser.add_argument("level", choices=["easy", "medium", "hard"], help="Difficulty level")
args = parser.parse_args()

print(f"Selected level: {args.level}")

The choices mechanism works for both positional and optional arguments. You can pass any iterable, but a list of strings is the most common pattern. When the user provides an invalid choice, argparse prints the list of acceptable values directly in the error message:

$ python script.py easy
Selected level: easy

$ python script.py impossible
usage: script.py [-h] {easy,medium,hard}
script.py: error: argument level: invalid choice: 'impossible' (choose from 'easy', 'medium', 'hard')

The error message includes the argument name, the invalid value, and the valid choices, making it trivial for users to correct their mistake. For cases where the choices depend on other arguments, you’ll need to validate after parsing rather than relying on the choices parameter alone.

Default values

Most optional arguments should have sensible defaults so users don’t have to specify every option on every invocation. Use the default parameter to set fallback values. When an optional argument is omitted, args.name takes the default; when it’s supplied, the default is overridden. This pattern is essential for creating tools that are easy to use with zero configuration but still highly configurable when needed:

import argparse

parser = argparse.ArgumentParser()
parser.add_argument("--count", type=int, default=1, help="Number of times")
parser.add_argument("--name", default="World", help="Name to greet")
args = parser.parse_args()

for i in range(args.count):
    print(f"Hello, {args.name}!")

The default value’s type should match what the argument would produce if supplied. For type=int, provide an integer default. For action="store_true", the default is automatically False, so you don’t need to specify it unless you want the opposite. These type-matching defaults ensure that branching logic elsewhere in your program works consistently regardless of whether users provide explicit values:

$ python script.py
Hello, World!

$ python script.py --count 3 --name Alice
Hello, Alice!
Hello, Alice!
Hello, Alice!

Defaults make your CLI tools discoverable. A new user can run the script with no arguments and get reasonable behavior, then explore the help text to find additional options. This progressive disclosure pattern is a hallmark of well-designed command-line interfaces, balancing immediate usability for newcomers with the configurability that power users expect from a mature tool.

Required options

Sometimes an option should be required even though it uses the -- prefix. This might seem contradictory since optional arguments are supposed to be, well, optional. But there are cases where a named, keyword-style argument makes more semantic sense than a positional one, even when the value must always be provided. Use required=True for this:

import argparse

parser = argparse.ArgumentParser()
parser.add_argument("--config", required=True, help="Configuration file path")
args = parser.parse_args()

print(f"Using config: {args.config}")

A required option with --config reads more clearly than a positional argument for the same purpose, because the user writes --config app.yml which declares intent rather than relying on argument position. If the user omits --config, argparse prints an error asking for it, just as it would for a missing positional argument.

When deciding between required options and positional arguments, ask yourself: would a reader understand what this value represents if they saw it without a label? If the answer is no, a required option is probably the right choice.

Argument groups

As your CLI grows, the help output can become cluttered. Grouping related arguments together makes the help text easier to scan and communicates which options belong together conceptually. Use add_argument_group to create labeled groups that appear as separate sections in the help output:

import argparse

parser = argparse.ArgumentParser(description="A mailer program")

group = parser.add_argument_group("email options")
group.add_argument("--to", help="Recipient email address")
group.add_argument("--from", dest="sender", help="Sender email address")
group.add_argument("--subject", help="Email subject")

args = parser.parse_args()

The dest parameter on --from is necessary because from is a Python keyword, so you can’t access args.from directly. This attribute remapping technique becomes essential whenever a CLI flag name collides with Python syntax. By setting dest="sender", you make the value accessible as args.sender while keeping the CLI flag as --from. This is a common workaround for arguments whose names conflict with Python reserved words.

Subcommands

Large CLI tools often have subcommands that represent distinct operations: git commit, git push, git pull. Argparse supports this pattern through subparsers, where each subcommand gets its own parser with its own set of arguments. The top-level parser dispatches to the correct subparser based on the first positional argument:

import argparse

parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest="command", help="Available commands")

# 'add' subcommand
add_parser = subparsers.add_parser("add", help="Add numbers")
add_parser.add_argument("x", type=int)
add_parser.add_argument("y", type=int)

# 'multiply' subcommand
mult_parser = subparsers.add_parser("multiply", help="Multiply numbers")
mult_parser.add_argument("x", type=int)
mult_parser.add_argument("y", type=int)

args = parser.parse_args()

if args.command == "add":
    print(f"Result: {args.x + args.y}")
elif args.command == "multiply":
    print(f"Result: {args.x * args.y}")

The dest="command" parameter stores the subcommand name in args.command, which your code then uses to decide which operation to perform. Each subparser defines its own positional and optional arguments independently, so the add subcommand and the multiply subcommand can have completely different argument signatures:

$ python calc.py add 5 3
Result: 8

$ python calc.py multiply 5 3
Result: 15

$ python calc.py --help
usage: calc.py [-h] {add,multiply} ...

positional arguments:
  {add,multiply}

options:
  -h, --help   show this help message and exit

Subcommands scale well for tools with many operations. Instead of a single parser with dozens of mutually exclusive arguments, you get clean separation between commands. Each subparser also gets its own --help output, so users can run git commit --help to see commit-specific options without wading through the entire git manual.

ArgumentParser basics

Beyond add_argument, the ArgumentParser constructor itself accepts several useful parameters that control the help output. The description appears at the top of the help text, the epilog at the bottom (useful for examples), and formatter_class controls how text is wrapped and displayed:

parser = argparse.ArgumentParser(
    description="My CLI tool",
    epilog="Example: mytool.py --verbose input.txt",
    formatter_class=argparse.RawDescriptionHelpFormatter,
)

The RawDescriptionHelpFormatter preserves whitespace and line breaks in your description and epilog text, which is essential if you want to include formatted examples or ASCII art in your help output. Other formatter classes include RawTextHelpFormatter and ArgumentDefaultsHelpFormatter, which automatically appends default values to every argument’s help text. These formatter classes are small configuration choices that have a big impact on how professional your tool feels to users. These small configuration choices have a big impact on how professional your tool feels to users.

Putting it together

Here’s a complete example that combines positional arguments, optional flags, type conversion, choices, and defaults into a single script. This is a realistic template for a data-processing CLI tool that accepts an input file and produces an output file, with configurable processing options:

#!/usr/bin/env python3
import argparse

def main():
    parser = argparse.ArgumentParser(
        description="Process a data file"
    )
    parser.add_argument(
        "input",
        help="Input file path"
    )
    parser.add_argument(
        "-o", "--output",
        default="output.txt",
        help="Output file path (default: output.txt)"
    )
    parser.add_argument(
        "-v", "--verbose",
        action="store_true",
        help="Enable verbose output"
    )
    parser.add_argument(
        "-n", "--lines",
        type=int,
        default=10,
        help="Number of lines to process (default: 10)"
    )
    parser.add_argument(
        "--format",
        choices=["json", "csv", "xml"],
        default="json",
        help="Output format (default: json)"
    )

    args = parser.parse_args()

    if args.verbose:
        print(f"Processing: {args.input}")
        print(f"Output to: {args.output}")
        print(f"Format: {args.format}")
        print(f"Lines: {args.lines}")

    # Your processing logic here
    print(f"Done! Processed {args.lines} lines.")

if __name__ == "__main__":
    main()

Run with --help to see the complete, auto-generated interface. This example demonstrates how argparse handles all the plumbing: parsing, type checking, validation against choices, and help text generation. Building CLI tools with argparse means you spend your time on your tool’s logic, not on argument parsing boilerplate.

Frequently asked questions

Can I use argparse with async Python code? Yes. Argparse handles argument parsing synchronously at startup — it doesn’t interact with the event loop. Parse your arguments first, then pass the result to your async functions. There is no async version of parse_args() because none is needed.

How do I handle mutually exclusive arguments? Use parser.add_mutually_exclusive_group(). This creates a group where at most one argument can be supplied. Pass required=True to the group constructor if you want exactly one of the options to be mandatory.

What’s the difference between nargs="?" and nargs="*"? nargs="?" accepts zero or one value — if none is provided, argparse uses the default or const. nargs="*" accepts zero or more values and collects them into a list. Use "?" for optional single values and "*" for optional lists.

Does argparse support environment variables? Not natively. However, you can set default=os.environ.get("MY_VAR") for individual arguments. For a more systematic approach, consider combining argparse with a library like python-dotenv for loading defaults from a .env file.

See Also