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.
# 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:
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.
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
| Parameter | Meaning |
|---|---|
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.
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:
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.
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:
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.
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:
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.
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:
Namespace(verbose=3, dry_run=True, include=['src', 'tests'])
For Python 3.9+ a built-in BooleanOptionalAction adds --flag/--no-flag pairs:
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.
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:
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:
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:
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.
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:
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.
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:
TextIOWrapper True
FileTypeopens the file eagerly and never closes it. For long-running programs or large files, prefertype=pathlib.Pathand open inside awithblock. UseFileTypefor 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.
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:
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 …).
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:
Namespace(api_key='secret-token', log_level='INFO')
For a structured pattern, write a helper:
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.
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:
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.
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:
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.
#!/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)
# 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:
build deploy test
For custom completion functions (e.g. listing files matching a glob), attach a completer attribute:
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.
| Feature | argparse | click | typer |
|---|---|---|---|
| Standard library | ✅ | ❌ | ❌ |
| Style | Imperative add_argument | Decorator-based | Type-hint-based |
| Subcommand groups | ✅ verbose | ✅ ergonomic | ✅ ergonomic |
| Prompts / confirmation | ❌ manual | ✅ click.prompt | ✅ typer.prompt |
| Env-var fallbacks | ❌ manual (os.environ.get) | ✅ envvar= on @click.option | ✅ envvar= on typer.Option |
| Tab completion | via argcomplete | ✅ built-in | ✅ built-in (via click) |
| Testing | manual parse_args | ✅ CliRunner | ✅ CliRunner (re-exported) |
| Rich output | ❌ | works with rich | ✅ first-class via typer[all] |
| Type validation | basic type= callable | basic + types | full 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
- Forgetting
required=Trueon subparsers — without it,args.cmdisNonewhen no subcommand is given and your dispatch silently does nothing. Setdest="cmd", required=Trueonadd_subparsers. - Mutable defaults via
default=[]—argparsereuses the same list across calls whenparse_argsis invoked multiple times in one process. Usedefault=Noneand convert to[]inside your handler, or useaction="append"withdefault=[](whichargparsehandles correctly). FileTypeleaks file handles — it opens but never closes. Prefertype=pathlib.Pathand awithblock in your code.nargs="?"with a positional argument is tricky —parser.add_argument("x", nargs="?", default="a")makesxoptional, but it conflicts with later positionals. Reserve?for the last positional argument.type=booldoes nothing useful —bool("False")isTruebecause the string is non-empty. Useaction="store_true"/"store_false"for flags, oraction=argparse.BooleanOptionalActionfor--flag/--no-flag.-hcollides with parent parsers — when usingparents=[…], passadd_help=Falseto the parent so only the final parser owns--help.--option=VALvs--option VAL— both work, but--option -1is parsed as a flag because-1looks like an option. Use--option=-1or insert--to end option parsing:mycli -- -1.parse_args()callssys.exiton error — fine in scripts, surprising in tests. Useparser.parse_known_args()or wrap withtry/except SystemExitfor testing.- Subcommand
--helprequires the subcommand —mycli --helpshows the top-level help;mycli build --helpshows thebuildsubcommand's help. New users often miss this. metavarvsdest—metavaris the name shown in--help;destis the attribute name onNamespace. 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=…).
#!/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:])
python mycli.py -v add "buy milk" --tag shopping --tag urgent
python mycli.py list --limit 5
Output:
[~/.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.
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)
echo '[tool]
host = "myhost"
port = 9000' > config.toml
python app.py
python app.py --port 9100
Output:
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.
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))
echo "hello, alice" | python filter.py --mode upper
python filter.py notes.txt -o notes.upper.txt --mode upper
Output:
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.
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:
============================ test session starts ============================
collected 2 items
test_mycli.py .. [100%]
========================== 2 passed in 0.03s ===========================