Build a Python CLI with argparse

Published on: July 10, 2026
Reading time: 5 minutes
Logo do Python com o texto 'Python argparse CLI' abaixo

A command-line interface turns a Python script into a tool that can be controlled from a terminal. Instead of editing source code whenever an input changes, users pass filenames, flags, formats, limits, and subcommands when they run the program. The standard-library argparse module handles this parsing and generates help and error messages automatically.

This guide builds a Python CLI with argparse from a single positional argument to a complete file-statistics application. You will learn optional arguments, Boolean flags, types, choices, defaults, required options, mutually exclusive options, subcommands, validation, testing, and modern packaging with pyproject.toml.

For lower-level command-line access, the Python sys module guide explains sys.argv. Argparse reads that list for you and provides a structured interface on top of it.

Why Use argparse?

A hand-written parser can read sys.argv, but it must implement help, missing-value checks, type conversion, usage text, option aliases, and invalid-input errors. Argparse centralizes those responsibilities.

The official Python argparse documentation describes the parser, actions, formatters, subparsers, and error behavior. Argparse is included with Python; no package installation is required.

Create a Minimal CLI

Save this file as greet.py:

import argparse

parser = argparse.ArgumentParser(
    description="Print a personalized greeting."
)
parser.add_argument("name", help="Name of the person to greet")
args = parser.parse_args()

print(f"Hello, {args.name}!")

Run it:

python greet.py Ava

Argparse stores the positional value in args.name. Run python greet.py --help to see generated usage and help text.

Positional and Optional Arguments

A positional argument is identified by its location:

parser.add_argument("filename")

An optional argument begins with a flag:

parser.add_argument("-o", "--output")

The short and long forms refer to the same value. Long descriptive names improve scripts and documentation; short forms are convenient for frequent terminal use.

Convert Types

Command-line input starts as text. Use type to convert and validate common values:

parser.add_argument(
    "--limit",
    type=int,
    default=10,
    help="Maximum number of results (default: 10)",
)

When the user supplies invalid text, argparse prints an error and exits instead of passing a bad value into the application.

Use pathlib.Path for paths

from pathlib import Path

parser.add_argument("input_file", type=Path)

This creates a Path object directly. The Python pathlib guide covers existence checks, reading, writing, and safe path handling.

Create Boolean Flags

A flag can become true when it appears:

parser.add_argument(
    "-v",
    "--verbose",
    action="store_true",
    help="Show detailed output",
)

Without the flag, args.verbose is false. With --verbose, it is true.

For a setting that defaults to true and can be disabled, modern argparse also supports Boolean optional actions:

parser.add_argument(
    "--color",
    action=argparse.BooleanOptionalAction,
    default=True,
)

This creates forms such as --color and --no-color.

Restrict Values with choices

parser.add_argument(
    "--format",
    choices=["text", "json", "csv"],
    default="text",
)

Argparse rejects values outside the list and displays the accepted choices in the help output.

Accept Several Values

Use nargs when one argument accepts multiple values:

parser.add_argument(
    "files",
    nargs="+",
    type=Path,
    help="One or more files",
)
nargsMeaning
?Zero or one value.
*Zero or more values.
+One or more values.
IntegerExactly that number of values.

Repeat an Option with action=”append”

parser.add_argument(
    "--tag",
    action="append",
    default=[],
    help="Add a tag; repeat the option for several tags",
)

Running --tag python --tag cli produces ["python", "cli"].

Make Options Mutually Exclusive

When two modes cannot be active together, let argparse enforce the rule:

output_group = parser.add_mutually_exclusive_group()
output_group.add_argument("--quiet", action="store_true")
output_group.add_argument("--verbose", action="store_true")

Passing both options produces a clear usage error.

Separate Parser Creation from Application Logic

A parser-building function makes testing and reuse easier:

def build_parser():
    parser = argparse.ArgumentParser(
        description="Inspect a text file."
    )
    parser.add_argument("path", type=Path)
    parser.add_argument("--encoding", default="utf-8")
    return parser


def main(argv=None):
    args = build_parser().parse_args(argv)
    print(args.path, args.encoding)

Passing argv lets a test provide arguments directly instead of modifying the process-wide sys.argv.

Validate Values That Need Custom Logic

A type function can reject invalid values with ArgumentTypeError:

def positive_integer(value):
    try:
        number = int(value)
    except ValueError as error:
        raise argparse.ArgumentTypeError(
            f"{value!r} is not an integer"
        ) from error

    if number < 1:
        raise argparse.ArgumentTypeError(
            "the value must be at least 1"
        )

    return number
parser.add_argument("--limit", type=positive_integer)

Use parser-level validation for relationships between several arguments after parsing.

Build Subcommands

Subcommands organize related operations such as add, list, and remove.

parser = argparse.ArgumentParser(prog="tasks")
subparsers = parser.add_subparsers(dest="command", required=True)

add_parser = subparsers.add_parser("add", help="Add a task")
add_parser.add_argument("title")

list_parser = subparsers.add_parser("list", help="List tasks")
list_parser.add_argument("--completed", action="store_true")

remove_parser = subparsers.add_parser("remove", help="Remove a task")
remove_parser.add_argument("task_id", type=int)

Dispatch with the selected command, or attach a handler function to each subparser:

def handle_add(args):
    print(f"Adding: {args.title}")


add_parser.set_defaults(handler=handle_add)
args = parser.parse_args()
args.handler(args)

Complete Project: File Statistics CLI

The following tool counts lines, words, and characters in one or more text files. It supports JSON output, recursive directory searching, encoding selection, verbose logging, and a progress bar.

import argparse
import json
import logging
from pathlib import Path


def positive_integer(value):
    try:
        number = int(value)
    except ValueError as error:
        raise argparse.ArgumentTypeError(
            f"{value!r} is not an integer"
        ) from error

    if number < 1:
        raise argparse.ArgumentTypeError(
            "the value must be at least 1"
        )

    return number


def build_parser():
    parser = argparse.ArgumentParser(
        prog="textstats",
        description="Count lines, words, and characters in text files.",
        epilog=(
            "Example: textstats notes.txt --format json"
        ),
    )
    parser.add_argument(
        "paths",
        nargs="+",
        type=Path,
        help="Files or directories to process",
    )
    parser.add_argument(
        "-r",
        "--recursive",
        action="store_true",
        help="Search directories recursively",
    )
    parser.add_argument(
        "--pattern",
        default="*.txt",
        help="File pattern used for directories",
    )
    parser.add_argument(
        "--encoding",
        default="utf-8",
        help="Text encoding (default: utf-8)",
    )
    parser.add_argument(
        "--format",
        choices=["text", "json"],
        default="text",
    )
    parser.add_argument(
        "--limit",
        type=positive_integer,
        help="Process at most this many files",
    )
    parser.add_argument(
        "-v",
        "--verbose",
        action="store_true",
    )
    parser.add_argument(
        "--version",
        action="version",
        version="%(prog)s 1.0.0",
    )
    return parser


def collect_files(paths, pattern, recursive):
    files = []

    for path in paths:
        if path.is_file():
            files.append(path)
            continue

        if path.is_dir():
            iterator = (
                path.rglob(pattern)
                if recursive
                else path.glob(pattern)
            )
            files.extend(item for item in iterator if item.is_file())
            continue

        logging.warning("Path not found: %s", path)

    return sorted(set(files))


def inspect_file(path, encoding):
    text = path.read_text(encoding=encoding, errors="replace")
    return {
        "path": str(path),
        "lines": len(text.splitlines()),
        "words": len(text.split()),
        "characters": len(text),
    }


def format_text(results):
    for result in results:
        print(result["path"])
        print(f"  lines: {result['lines']}")
        print(f"  words: {result['words']}")
        print(f"  characters: {result['characters']}")


def main(argv=None):
    args = build_parser().parse_args(argv)
    logging.basicConfig(
        level=logging.INFO if args.verbose else logging.WARNING,
        format="%(levelname)s: %(message)s",
    )

    files = collect_files(
        args.paths,
        args.pattern,
        args.recursive,
    )

    if args.limit is not None:
        files = files[: args.limit]

    if not files:
        raise SystemExit("No matching files found.")

    logging.info("Processing %d files", len(files))
    results = [inspect_file(path, args.encoding) for path in files]

    if args.format == "json":
        print(json.dumps(results, indent=2))
    else:
        format_text(results)

    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Example commands:

python textstats.py notes.txt
python textstats.py documents --recursive
python textstats.py documents -r --format json --limit 20
python textstats.py --help

The script uses the Python logging module for diagnostic output and keeps normal results on standard output.

Add tqdm Progress

After installing tqdm, replace the result comprehension with:

from tqdm import tqdm

results = [
    inspect_file(path, args.encoding)
    for path in tqdm(files, desc="Reading", unit="file")
]

The Python tqdm guide covers quiet modes and non-interactive terminals.

Test Argument Parsing

from pathlib import Path
from textstats import build_parser


def test_parser_reads_options():
    args = build_parser().parse_args(
        ["notes.txt", "--format", "json", "--limit", "5"]
    )

    assert args.paths == [Path("notes.txt")]
    assert args.format == "json"
    assert args.limit == 5

Test business logic separately from parsing. The Python unit testing guide explains temporary files, mocks, and subprocess tests.

Test the Real Command with subprocess

import subprocess
import sys


def test_help_command():
    result = subprocess.run(
        [sys.executable, "textstats.py", "--help"],
        capture_output=True,
        text=True,
        check=False,
    )

    assert result.returncode == 0
    assert "Count lines" in result.stdout

This checks the real executable behavior, including exit codes and generated help.

Package the CLI with pyproject.toml

Modern Python packages normally declare command entry points in pyproject.toml rather than requiring a legacy setup.py.

[project]
name = "textstats-cli"
version = "0.1.0"
description = "Text file statistics command"
requires-python = ">=3.10"

[project.scripts]
textstats = "textstats.cli:main"

After the package is installed, the environment creates a textstats command that calls textstats.cli.main. The official Python Packaging User Guide explains executable scripts and project metadata. The installable Python package guide covers a complete package structure.

Exit Codes

A successful command normally exits with zero. Invalid argparse input exits with a nonzero code automatically. Application errors can use raise SystemExit(message) or return an integer from main() and pass it to SystemExit.

Do not use exceptions as ordinary control flow everywhere. Reserve nonzero exits for cases where the command could not complete as requested.

Common Mistakes

  • Parsing arguments at import time, which makes tests and reuse harder.
  • Using type=bool for flags. Use actions such as store_true.
  • Putting all application logic directly after parse_args().
  • Failing to include useful help text and examples.
  • Using string paths everywhere when Path would be clearer.
  • Printing errors to normal output when scripts need to pipe results.
  • Publishing a CLI with only a legacy setup.py example.
  • Testing only happy paths and ignoring missing or invalid arguments.

Frequently Asked Questions

Do I need to install argparse?

No. It is part of the standard library.

How do I create a flag with no value?

Use action="store_true", store_false, or BooleanOptionalAction.

How do I make a subcommand required?

Use add_subparsers(required=True) on supported modern Python versions.

Can I parse a custom argument list?

Yes. Pass a list to parse_args(list_of_strings). Without it, argparse reads sys.argv.

How do I show a version?

Add an argument with action="version".

When should I use Click or Typer instead?

Argparse is dependency-free and capable. Third-party frameworks may offer decorators, rich output, type-hint-driven commands, or different composition models for larger applications.

Conclusion

A Python CLI with argparse gains reliable parsing, automatic help, type conversion, choices, flags, and subcommands without an external dependency. The most maintainable design builds the parser in one function, keeps business logic elsewhere, and lets main(argv=None) accept test arguments.

Begin with one positional argument and one flag. Add validation, subcommands, tests, and packaging only as the tool grows. A small, predictable command is more useful than a complicated interface with unclear behavior.

Share:

Facebook
WhatsApp
Twitter
LinkedIn

Article content

    Related articles

    Sistema de backup automático de arquivos usando Python
    Automation and Scripts
    Foto de perfil de Leandro Hirt da Academify

    Automate File Backups with Python

    Learn how to automate file backups with Python using shutil, os, pathlib, and datetime: define source/destination, copy folders, and schedule

    Ler mais

    Tempo de leitura: 3 minutos
    03/06/2026
    Compactação de arquivos ZIP usando Python
    Automation and Scripts
    Foto de perfil de Leandro Hirt da Academify

    Unzip .zip Files in Python Without Errors

    Learn how to unzip .zip files in Python without errors using the zipfile module: extractall, testzip integrity check, extract specific

    Ler mais

    Tempo de leitura: 3 minutos
    03/06/2026
    Execução de comandos do terminal usando Python
    Automation and Scripts
    Foto de perfil de Leandro Hirt da Academify

    Run Terminal Commands with Python in 2 Minutes

    Run terminal commands from Python using subprocess.run: capture output, handle errors with check=True, use shell=True for pipes, and build a

    Ler mais

    Tempo de leitura: 3 minutos
    01/06/2026
    Script Python configurado para iniciar junto com o Windows
    Automation and Scripts
    Foto de perfil de Leandro Hirt da Academify

    How to Run a Python Script Automatically on Windows Startup

    Learn how to run Python scripts on Windows startup with the Startup folder, Task Scheduler, .bat files, pythonw.exe, and complete

    Ler mais

    Tempo de leitura: 8 minutos
    19/05/2026
    Web scraper de notícias em Python com envio para Telegram
    Automation and Scripts
    Foto de perfil de Leandro Hirt da Academify

    Build a News Scraper to Telegram with Python

    Learn how to build a Python web scraper that extracts news headlines and sends them to Telegram automatically. Complete guide

    Ler mais

    Tempo de leitura: 9 minutos
    12/05/2026
    Acesso e edição de Google Sheets com Python
    Automation and Scripts
    Foto de perfil de Leandro Hirt da Academify

    Access and Edit Google Sheets with Python

    Learn to access and edit Google Sheets with Python using gspread, including API setup, authentication, reading, writing, and automation.

    Ler mais

    Tempo de leitura: 9 minutos
    12/05/2026