cheat sheet

argparse

Parse command-line arguments with Python's stdlib argparse module. Covers positional/optional args, subparsers, nargs, validation, mutually exclusive groups, FileType, env-var fallbacks, and argcomplete.

argparse — Stdlib CLI Argument Parser

What it is

argparse is the standard-library module for building command-line interfaces in Python. It parses sys.argv, validates argument types, generates --help text automatically, and dispatches to subcommands. It ships with every Python install (3.2+), so reach for it whenever you cannot or do not want to add a third-party dependency. For richer ergonomics, prefer click or typer; argparse is the right call when zero dependencies is a hard constraint.

Install

argparse is part of the standard library — no install step is needed. To use the optional shell-completion helper argcomplete, install it separately.

bash
# argparse — already included with Python 3.2+
python -c "import argparse; print(argparse.__version__)"

# Optional: tab completion
pip install argcomplete
activate-global-python-argcomplete --user

Output:

text
1.1

Syntax

An argparse program creates an ArgumentParser, registers arguments with add_argument, then calls parse_args(). The parser returns a Namespace object whose attributes are the parsed values.

python
import argparse

parser = argparse.ArgumentParser(description="What this tool does")
parser.add_argument("input")                          # positional
parser.add_argument("-o", "--output", default="out")  # optional
args = parser.parse_args()
print(args.input, args.output)

Output: (none — defines the parser; values appear when invoked from the shell)

Essential parameters of add_argument

ParameterMeaning
type=Callable that converts the raw string (int, float, pathlib.Path, a custom function)
default=Value when the flag is omitted
choices=Iterable of allowed values; rejects anything else
required=For optional args (--foo); makes them mandatory
nargs=Number of values: ?, *, +, N, or argparse.REMAINDER
action=What to do with the value: store, store_true, store_false, append, count, extend
dest=Attribute name on the resulting Namespace (defaults to the long-flag name)
metavar=Display name in --help
help=Help string (use "%(default)s" to interpolate the default)

Positional vs optional arguments

Positional arguments are required and identified by order; optional arguments start with - or -- and can appear in any order. Mixing both is the usual pattern for real-world tools.

python
import argparse

parser = argparse.ArgumentParser(prog="copyfile")
parser.add_argument("src")                                    # positional
parser.add_argument("dst")                                    # positional
parser.add_argument("-v", "--verbose", action="store_true")   # optional flag
parser.add_argument("--mode", default="copy",
                    choices=["copy", "move", "link"])         # optional with choices

args = parser.parse_args(["a.txt", "b.txt", "--mode", "move", "-v"])
print(args)

Output:

text
Namespace(src='a.txt', dst='b.txt', verbose=True, mode='move')

nargs — variable-length arguments

nargs lets a single argument capture multiple values: ? for zero-or-one, * for zero-or-more, + for one-or-more, or a fixed integer N for exactly that many.

python
import argparse

parser = argparse.ArgumentParser()
parser.add_argument("files", nargs="+")               # at least one
parser.add_argument("--tags", nargs="*", default=[])  # any number
parser.add_argument("--coord", nargs=2, type=float)   # exactly two
parser.add_argument("--out", nargs="?", const="-",    # ? + const
                    default=None)

args = parser.parse_args(
    ["a.txt", "b.txt", "--tags", "x", "y", "--coord", "1.5", "2.0", "--out"]
)
print(args)

Output:

text
Namespace(files=['a.txt', 'b.txt'], tags=['x', 'y'], coord=[1.5, 2.0], out='-')

The ? form together with const covers the common --flag (use const), --flag VAL (use VAL), or no flag (use default) pattern.

type= and custom validators

The type parameter is any callable that takes a single string and returns a value, raising argparse.ArgumentTypeError to surface a nice error. Use it to plug in domain validation alongside the built-in int/float/Path converters.

python
import argparse
import re
from pathlib import Path

def positive_int(value: str) -> int:
    n = int(value)
    if n <= 0:
        raise argparse.ArgumentTypeError(f"{value} must be > 0")
    return n

def hostname(value: str) -> str:
    if not re.match(r"^[a-zA-Z0-9.-]+$", value):
        raise argparse.ArgumentTypeError(f"invalid hostname: {value!r}")
    return value

parser = argparse.ArgumentParser()
parser.add_argument("--workers", type=positive_int, default=4)
parser.add_argument("--host", type=hostname, default="myhost")
parser.add_argument("--config", type=Path, default=Path.home() / ".config")

args = parser.parse_args(["--workers", "8", "--host", "myhost"])
print(args)

Output:

text
Namespace(workers=8, host='myhost', config=PosixPath('/home/alice/.config'))

Pass --workers 0 and you get a clean error: argument --workers: 0 must be > 0.

action= — flags, counters, and accumulators

action controls what argparse does when it sees the argument. Beyond the default store, the most useful actions are store_true/store_false for boolean flags, count for repeatable verbosity, and append to collect values across repeated flags.

python
import argparse

parser = argparse.ArgumentParser()
parser.add_argument("-v", "--verbose", action="count", default=0)
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("-I", "--include", action="append", default=[])

args = parser.parse_args(["-vvv", "--dry-run", "-I", "src", "-I", "tests"])
print(args)

Output:

text
Namespace(verbose=3, dry_run=True, include=['src', 'tests'])

For Python 3.9+ a built-in BooleanOptionalAction adds --flag/--no-flag pairs:

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

This accepts --cache (True), --no-cache (False), and falls back to the default if neither is given.

Subparsers — git-style subcommands

add_subparsers turns a single CLI into a dispatcher with named subcommands, each with its own arguments and help text. Each subparser is a fully-fledged ArgumentParser, so it can have its own nargs, choices, and even nested subparsers.

python
import argparse

parser = argparse.ArgumentParser(prog="notes")
sub = parser.add_subparsers(dest="cmd", required=True, metavar="COMMAND")

p_add = sub.add_parser("add", help="Add a note")
p_add.add_argument("text")
p_add.add_argument("--tag", action="append", default=[])

p_list = sub.add_parser("list", help="List notes")
p_list.add_argument("--limit", type=int, default=10)

p_rm = sub.add_parser("rm", help="Remove a note by id")
p_rm.add_argument("note_id", type=int)

args = parser.parse_args(["add", "buy milk", "--tag", "shopping"])
print(args)

Output:

text
Namespace(cmd='add', text='buy milk', tag=['shopping'])

Dispatching is usually done with a small if/elif block or by attaching a function to each subparser via set_defaults:

python
p_add.set_defaults(func=lambda a: print(f"adding: {a.text}"))
p_list.set_defaults(func=lambda a: print(f"listing {a.limit} notes"))

args = parser.parse_args(["list", "--limit", "5"])
args.func(args)

Output:

text
listing 5 notes

Mutually exclusive groups

add_mutually_exclusive_group() creates a group where at most one (or exactly one, if required=True) of the registered arguments may appear on the command line.

python
import argparse

parser = argparse.ArgumentParser()
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--json", action="store_true")
group.add_argument("--yaml", action="store_true")
group.add_argument("--toml", action="store_true")

args = parser.parse_args(["--yaml"])
print(args)

Output:

text
Namespace(json=False, yaml=True, toml=False)

Passing two of the flags fails with argument --yaml: not allowed with argument --json. Use add_argument_group("Output options") (without "mutually exclusive") when you only want to organise --help into sections, not enforce exclusivity.

FileType — built-in file handling

argparse.FileType("r") opens the file for you when the argument is parsed, returning a file object. It accepts - for stdin/stdout, which is the standard CLI idiom for piping.

python
import argparse
import sys

parser = argparse.ArgumentParser()
parser.add_argument("infile",  type=argparse.FileType("r"),
                    nargs="?", default=sys.stdin)
parser.add_argument("--outfile", type=argparse.FileType("w"),
                    default=sys.stdout)

args = parser.parse_args(["-"])  # read from stdin
print(type(args.infile).__name__, args.outfile is sys.stdout)

Output:

text
TextIOWrapper True

FileType opens the file eagerly and never closes it. For long-running programs or large files, prefer type=pathlib.Path and open inside a with block. Use FileType for short scripts where the file is consumed immediately.

Custom type= for files — the modern alternative

For real applications, define a tiny converter that validates the path exists, returns a Path, and lets your code own the open/close lifecycle.

python
import argparse
from pathlib import Path

def existing_file(value: str) -> Path:
    p = Path(value)
    if not p.is_file():
        raise argparse.ArgumentTypeError(f"not a file: {value!r}")
    return p

parser = argparse.ArgumentParser()
parser.add_argument("config", type=existing_file)
args = parser.parse_args(["pyproject.toml"])
print(type(args.config).__name__, args.config.name)

Output:

text
PosixPath pyproject.toml

Env-var fallbacks

argparse has no native env-var support, but the idiom is one line — read the env var and pass it as default=. This is how you make a CLI both scriptable (MYAPP_API_KEY=…) and explicit (--api-key …).

python
import argparse
import os

parser = argparse.ArgumentParser()
parser.add_argument(
    "--api-key",
    default=os.environ.get("MYAPP_API_KEY"),
    required="MYAPP_API_KEY" not in os.environ,
    help="API key (env: MYAPP_API_KEY)",
)
parser.add_argument(
    "--log-level",
    default=os.environ.get("MYAPP_LOG_LEVEL", "INFO"),
    choices=["DEBUG", "INFO", "WARNING", "ERROR"],
)

args = parser.parse_args(["--api-key", "secret-token"])
print(args)

Output:

text
Namespace(api_key='secret-token', log_level='INFO')

For a structured pattern, write a helper:

python
def env_default(name: str, fallback=None):
    return {"default": os.environ.get(name, fallback),
            "help": f"(env: {name})"}

parser.add_argument("--host", **env_default("MYAPP_HOST", "myhost"))
parser.add_argument("--port", type=int, **env_default("MYAPP_PORT", 8080))

Output: (none — registers two args with env-var fallbacks)

Parent parsers — sharing flags across subcommands

parents=[...] lets multiple parsers inherit a common set of arguments. Use it to share --verbose, --config, or other global flags across subcommands without copy-pasting add_argument calls.

python
import argparse

common = argparse.ArgumentParser(add_help=False)
common.add_argument("-v", "--verbose", action="count", default=0)
common.add_argument("--config", default="~/.config/app.toml")

parser = argparse.ArgumentParser(prog="app", parents=[common])
sub = parser.add_subparsers(dest="cmd", required=True)

sub.add_parser("build", parents=[common], help="Build artifacts")
sub.add_parser("test",  parents=[common], help="Run tests")

args = parser.parse_args(["-vv", "build", "--config", "myapp.toml"])
print(args)

Output:

text
Namespace(verbose=2, config='myapp.toml', cmd='build')

add_help=False on the parent is critical — otherwise both parser and child try to register -h/--help and conflict.

Config-file fallback with fromfile_prefix_chars

fromfile_prefix_chars="@" tells argparse that arguments starting with @ are file paths whose contents (one arg per line) should be substituted in. This gives you free @args.txt support for response files.

python
import argparse
from pathlib import Path

Path("args.txt").write_text("--workers\n8\n--host\nmyhost\n")

parser = argparse.ArgumentParser(fromfile_prefix_chars="@")
parser.add_argument("--workers", type=int, default=1)
parser.add_argument("--host", default="localhost")

args = parser.parse_args(["@args.txt"])
print(args)

Output:

text
Namespace(workers=8, host='myhost')

Tab completion with argcomplete

argcomplete adds bash/zsh/fish tab completion to any argparse-based CLI with two lines of code. Install once, register a shebang/hook, then use argcomplete.autocomplete(parser) before parse_args.

python
#!/usr/bin/env python
# PYTHON_ARGCOMPLETE_OK
import argparse, argcomplete

parser = argparse.ArgumentParser()
parser.add_argument("--mode", choices=["build", "test", "deploy"])
parser.add_argument("--target", choices=["dev", "staging", "prod"])

argcomplete.autocomplete(parser)
args = parser.parse_args()
print(args)
bash
# One-time setup
activate-global-python-argcomplete --user
# OR for a single script
eval "$(register-python-argcomplete ./mycli.py)"

# Now TAB completes choices:
./mycli.py --mode <TAB><TAB>

Output:

text
build  deploy  test

For custom completion functions (e.g. listing files matching a glob), attach a completer attribute:

python
def project_completer(prefix, **kwargs):
    return [p.name for p in Path("projects").glob(f"{prefix}*")]

parser.add_argument("--project").completer = project_completer

Comparison with click and typer

argparse is the right tool for zero-dependency scripts; click and typer trade a dependency for a much higher level of ergonomics. The decision tree below covers the common cases.

Featureargparseclicktyper
Standard library
StyleImperative add_argumentDecorator-basedType-hint-based
Subcommand groups✅ verbose✅ ergonomic✅ ergonomic
Prompts / confirmation❌ manualclick.prompttyper.prompt
Env-var fallbacks❌ manual (os.environ.get)envvar= on @click.optionenvvar= on typer.Option
Tab completionvia argcomplete✅ built-in✅ built-in (via click)
Testingmanual parse_argsCliRunnerCliRunner (re-exported)
Rich outputworks with rich✅ first-class via typer[all]
Type validationbasic type= callablebasic + typesfull Python type annotations

Rule of thumb: pick argparse if you cannot add a dependency or are writing a 50-line one-off script. Pick click if you want a thoughtful decorator API and prompts. Pick typer if you already use type hints everywhere (or FastAPI) and want the same idioms in your CLI.

Common pitfalls

  1. Forgetting required=True on subparsers — without it, args.cmd is None when no subcommand is given and your dispatch silently does nothing. Set dest="cmd", required=True on add_subparsers.
  2. Mutable defaults via default=[]argparse reuses the same list across calls when parse_args is invoked multiple times in one process. Use default=None and convert to [] inside your handler, or use action="append" with default=[] (which argparse handles correctly).
  3. FileType leaks file handles — it opens but never closes. Prefer type=pathlib.Path and a with block in your code.
  4. nargs="?" with a positional argument is trickyparser.add_argument("x", nargs="?", default="a") makes x optional, but it conflicts with later positionals. Reserve ? for the last positional argument.
  5. type=bool does nothing usefulbool("False") is True because the string is non-empty. Use action="store_true"/"store_false" for flags, or action=argparse.BooleanOptionalAction for --flag/--no-flag.
  6. -h collides with parent parsers — when using parents=[…], pass add_help=False to the parent so only the final parser owns --help.
  7. --option=VAL vs --option VAL — both work, but --option -1 is parsed as a flag because -1 looks like an option. Use --option=-1 or insert -- to end option parsing: mycli -- -1.
  8. parse_args() calls sys.exit on error — fine in scripts, surprising in tests. Use parser.parse_known_args() or wrap with try/except SystemExit for testing.
  9. Subcommand --help requires the subcommandmycli --help shows the top-level help; mycli build --help shows the build subcommand's help. New users often miss this.
  10. metavar vs destmetavar is the name shown in --help; dest is the attribute name on Namespace. Set both explicitly when the auto-derived versions look ugly.

Real-world recipes

A CLI with subcommands, shared flags, and env-var fallbacks

A full skeleton for a multi-command tool with --config/--verbose shared across subcommands, env-var fallbacks, and dispatch via set_defaults(func=…).

python
#!/usr/bin/env python
"""mycli — manage Alice's notes."""
import argparse
import os
import sys
from pathlib import Path

def env_default(name, fallback=None):
    return {"default": os.environ.get(name, fallback),
            "help": f"(env: {name})"}

def cmd_add(args):
    print(f"[{args.config}] adding: {args.text} tags={args.tag}")

def cmd_list(args):
    print(f"[{args.config}] listing {args.limit} notes (v={args.verbose})")

def cmd_rm(args):
    print(f"[{args.config}] removing #{args.note_id}")

def build_parser():
    common = argparse.ArgumentParser(add_help=False)
    common.add_argument("-v", "--verbose", action="count", default=0)
    common.add_argument("--config", type=Path,
                        **env_default("MYCLI_CONFIG", "~/.config/mycli.toml"))

    parser = argparse.ArgumentParser(prog="mycli", parents=[common])
    sub = parser.add_subparsers(dest="cmd", required=True)

    p_add = sub.add_parser("add", parents=[common], help="Add a note")
    p_add.add_argument("text")
    p_add.add_argument("--tag", action="append", default=[])
    p_add.set_defaults(func=cmd_add)

    p_list = sub.add_parser("list", parents=[common], help="List notes")
    p_list.add_argument("--limit", type=int, default=10)
    p_list.set_defaults(func=cmd_list)

    p_rm = sub.add_parser("rm", parents=[common], help="Delete a note")
    p_rm.add_argument("note_id", type=int)
    p_rm.set_defaults(func=cmd_rm)
    return parser

def main(argv=None):
    args = build_parser().parse_args(argv)
    args.func(args)

if __name__ == "__main__":
    main(sys.argv[1:])
bash
python mycli.py -v add "buy milk" --tag shopping --tag urgent
python mycli.py list --limit 5

Output:

text
[~/.config/mycli.toml] adding: buy milk tags=['shopping', 'urgent']
[~/.config/mycli.toml] listing 5 notes (v=0)

Reading TOML config with CLI overrides

Load a TOML config file (Python 3.11+ has tomllib in the stdlib), then let CLI flags override individual settings. Argparse's default= is the integration point.

python
import argparse
import os
import sys
import tomllib
from pathlib import Path

def load_config(path: Path) -> dict:
    if path.is_file():
        return tomllib.loads(path.read_text())
    return {}

parser = argparse.ArgumentParser()
parser.add_argument("--config", type=Path, default=Path("config.toml"))
parser.add_argument("--host")
parser.add_argument("--port", type=int)
parser.add_argument("--workers", type=int)

# Two-pass parse: first pull the --config flag, then load defaults, then re-parse.
pre, _ = parser.parse_known_args()
cfg = load_config(pre.config)

parser.set_defaults(
    host=os.environ.get("APP_HOST", cfg.get("host", "127.0.0.1")),
    port=int(os.environ.get("APP_PORT", cfg.get("port", 8080))),
    workers=int(os.environ.get("APP_WORKERS", cfg.get("workers", 4))),
)
args = parser.parse_args()
print(args)
bash
echo '[tool]
host = "myhost"
port = 9000' > config.toml
python app.py
python app.py --port 9100

Output:

text
Namespace(config=PosixPath('config.toml'), host='myhost', port=9000, workers=4)
Namespace(config=PosixPath('config.toml'), host='myhost', port=9100, workers=4)

Stdin/stdout filter — Unix-style

A classic cat | tool | cat filter that accepts an optional input file (defaulting to stdin), an optional output file (defaulting to stdout), and a --mode flag. This is the shape of jq, sed, and most Unix text tools.

python
import argparse
import sys

parser = argparse.ArgumentParser(description="Upper/lower-case text filter")
parser.add_argument("infile",  type=argparse.FileType("r"),
                    nargs="?", default=sys.stdin)
parser.add_argument("-o", "--outfile", type=argparse.FileType("w"),
                    default=sys.stdout)
parser.add_argument("-m", "--mode", choices=["upper", "lower"], default="upper")
args = parser.parse_args()

xform = str.upper if args.mode == "upper" else str.lower
for line in args.infile:
    args.outfile.write(xform(line))
bash
echo "hello, alice" | python filter.py --mode upper
python filter.py notes.txt -o notes.upper.txt --mode upper

Output:

text
HELLO, ALICE

Self-tests with parse_args in tests

Tests should call build_parser().parse_args([...]) directly, asserting on the returned Namespace. Wrap SystemExit to test invalid input cleanly.

python
import argparse
import pytest
from mycli import build_parser

def test_add_command():
    args = build_parser().parse_args(["add", "buy milk", "--tag", "shop"])
    assert args.cmd == "add"
    assert args.text == "buy milk"
    assert args.tag == ["shop"]

def test_missing_subcommand_exits():
    with pytest.raises(SystemExit):
        build_parser().parse_args([])

Output:

text
============================ test session starts ============================
collected 2 items

test_mycli.py ..                                                      [100%]
========================== 2 passed in 0.03s ===========================