You wrote a handy little script. Then a teammate asks, “can I point it at a different folder?” So you add a hardcoded path at the top. Then they want a “quiet” mode, so you add a global flag. Six edits later you are reading sys.argv by hand, splitting strings, and writing your own help text. This is exactly the mess that Python argparse and click clean up for you.
“A good CLI is like a good conversation: clear, forgiving, and predictable.”
Jeff Atwood, Coding Horror
Last Updated: July 2026 | Tested on: Python 3.14.6 | Difficulty: Intermediate | Reading Time: 19 minutes
Think of a command-line tool like ordering at a coffee shop. The barista (your script) needs to know three things: what you want (a positional argument like the drink), the options you picked (a flag like “extra hot”), and any settings (a value like “two shots”). Without a parser, you are the barista trying to guess all of that from one mumbled sentence. With a parser, every order arrives on a neat ticket: clearly labelled, already checked, ready to make.
argparse is built into Python, so there is nothing to install. It is a little wordy, but it always works and adds zero dependencies to your project. click is a third-party library that uses decorators to do the same job with less typing and a friendlier feel. Both give you the same finished product: a polished Command-Line Interface (CLI) with a --help screen, type checking, and clean error messages. The difference is mostly style, and by the end of this post you will know which one to reach for.
The diagram follows one command from top to bottom. A user, say a developer named Rahul, types something like python app.py --name Rahul -v. The parser (argparse or click) reads that line, checks each piece, and turns it into plain Python values: name='Rahul' and verbose=True. Your code then uses those values and prints a result. The four boxes on the left show the kinds of input a parser handles: a positional argument (the bare value), an option (a named value), a flag (an on/off switch), and a subcommand (a named action, the way git commit works).
The examples below build the same tool twice, once with argparse and once with click, so you can compare them side by side.
Table of Contents
Prerequisites
You should be comfortable with the functions tutorial and the decorators tutorial, because click leans heavily on decorators. You also want to be at home running scripts in a terminal, which the terminal setup guide covers. Code here was tested on Python 3.14.6 and click 8.4.1.
Install and Verify
Python argparse ships in the standard library, so there is nothing to install. click is a separate package. Install it into your project’s virtual environment and check the version so you know it landed.
📄 Terminal: install click and confirm both parsers are ready
# argparse needs no install, it is part of the standard library
python -c "import argparse, sys; print('argparse ships with Python', sys.version.split()[0])"
# click is third-party, so pip install it
pip install click
python -c "import click; from importlib.metadata import version; print('click', version('click'))"
▶ Output
argparse ships with Python 3.14.6 click 8.4.1
What happened here: The first command imports argparse and prints your Python version to prove the parser is already there. No pip install needed, ever. The second command installs click and reads its version straight from the package metadata. If both lines print without an error, you are ready for everything below.
Quick Win: Your First Parser
Before the full tour, here is the smallest useful thing argparse can do: take a number from the command line and square it. Normally parse_args() reads from the terminal, but you can hand it a list directly, which is perfect for showing the result right here.
📄 quickwin.py: a two-line parser that squares a number
import argparse
parser = argparse.ArgumentParser(description="Square a number")
parser.add_argument("number", type=int, help="The number to square")
args = parser.parse_args(["7"]) # normally this reads from the terminal
print(f"{args.number} squared is {args.number ** 2}")
▶ Output
7 squared is 49
What happened here: Two lines of setup gave you a real CLI. add_argument("number", type=int) says “expect one value, and turn it into an integer”. When we passed ["7"] (a string, like the terminal always sends), argparse converted it to the integer 7 for us. In a real script you would write parser.parse_args() with no list, and argparse would read whatever the user typed after the filename. That is the whole idea: you describe what you want, and the parser collects it, checks it, and converts it.
argparse: The Built-in Parser
Writing add_argument calls is like designing a paper form: each line says what goes in one box, which boxes are mandatory, and the clerk (argparse) rejects the form if a box is filled in wrong. Let us design one now: a real greeting tool you can point at any teammate, say Rahul or a colleague named Viraj. It takes a name (required), an optional count, and a flag to shout in uppercase. Three argument styles in one tiny script, and that form-designing mindset is all Python argparse really asks of you.
📄 greet_argparse.py: name, count, and a loud flag
import argparse
parser = argparse.ArgumentParser(
description="Greet someone from the command line"
)
parser.add_argument("name", help="Name of the person to greet")
parser.add_argument("-c", "--count", type=int, default=1, help="Number of greetings")
parser.add_argument("-l", "--loud", action="store_true", help="Greet in uppercase")
args = parser.parse_args()
for _ in range(args.count):
greeting = f"Hello, {args.name}!"
if args.loud:
greeting = greeting.upper()
print(greeting)
📄 Terminal: three ways to call the same tool
python greet_argparse.py Rahul python greet_argparse.py Viraj --count 3 --loud python greet_argparse.py --help
▶ Output
$ python greet_argparse.py Rahul Hello, Rahul! $ python greet_argparse.py Viraj --count 3 --loud HELLO, VIRAJ! HELLO, VIRAJ! HELLO, VIRAJ! $ python greet_argparse.py --help usage: greet_argparse.py [-h] [-c COUNT] [-l] name Greet someone from the command line positional arguments: name Name of the person to greet options: -h, --help show this help message and exit -c, --count COUNT Number of greetings -l, --loud Greet in uppercase
What happened here: Three add_argument calls described the whole interface. name has no dash, so it is positional: required, and identified by where it sits. --count takes a value and type=int converts it for you. --loud uses action="store_true", which makes it a flag: present means True, absent means False. You never wrote any help text by hand, yet --help built a full screen from your help= strings. One detail worth noticing: the help layout has changed over the years.
Python 3.10 renamed the section from optional arguments: to options:, and Python 3.13 stopped repeating the metavar, so 3.14 shows -c, --count COUNT instead of the old -c COUNT, --count COUNT. If you copied an older tutorial’s output, it will not match what your terminal actually prints.
click: The Decorator Approach
Here is the exact same greeting tool written with click. Think of the decorators as sticky notes on a recipe card: the recipe (your function) stays clean, and each note stuck above it declares one ingredient you expect. Watch how the arguments move into decorators stacked above the function, and the parsed values arrive as normal function parameters.
📄 greet_click.py: the same tool, written with decorators
import click
@click.command()
@click.argument("name")
@click.option("--count", "-c", default=1, help="Number of greetings")
@click.option("--loud", "-l", is_flag=True, help="Greet in uppercase")
def greet(name, count, loud):
"""Greet someone from the command line."""
for _ in range(count):
greeting = f"Hello, {name}!"
if loud:
greeting = greeting.upper()
click.echo(greeting)
if __name__ == "__main__":
greet()
📄 Terminal: same calls, now with the click version
python greet_click.py Rahul python greet_click.py Viraj -c 3 -l python greet_click.py --help
▶ Output
$ python greet_click.py Rahul Hello, Rahul! $ python greet_click.py Viraj -c 3 -l HELLO, VIRAJ! HELLO, VIRAJ! HELLO, VIRAJ! $ python greet_click.py --help Usage: greet_click.py [OPTIONS] NAME Greet someone from the command line. Options: -c, --count INTEGER Number of greetings -l, --loud Greet in uppercase --help Show this message and exit.
What happened here: Same behaviour, less ceremony. Each @click.option and @click.argument sits right above the function and becomes a parameter, so name, count, and loud arrive ready to use. Notice click figured out the type for --count from the default value 1, which is why the help screen says INTEGER without you asking. We used click.echo() instead of print() because it handles tricky character encoding the same way on Windows, macOS, and Linux, so your tool behaves identically everywhere. Compare the two help screens: argparse and click describe the same tool, just with their own house style.
Subcommands: git-style CLIs
Real tools rarely do just one thing. A subcommand tool is like a Swiss Army knife: one handle, many blades, and you fold out only the blade you need right now. Git is the classic example: git commit, git push, and git log are separate actions that live under one command. click calls this a group. You decorate a function with @click.group(), then hang individual commands off it. Here is a small file tool with two subcommands, list and move.
📄 file_tool.py: one tool, two subcommands
import click
from pathlib import Path
@click.group()
def cli():
"""A file management tool."""
pass
@cli.command()
@click.argument("directory", type=click.Path(exists=True))
@click.option("--ext", "-e", help="Filter by extension (e.g., .py)")
def list(directory, ext):
"""List files in a directory."""
p = Path(directory)
files = p.iterdir()
if ext:
files = [f for f in files if f.suffix == ext]
else:
files = [f for f in files]
for f in sorted(files):
size = f.stat().st_size if f.is_file() else 0
print(f" {f.name:30s} {size:>10,} bytes")
@cli.command()
@click.argument("source", type=click.Path(exists=True))
@click.argument("dest", type=click.Path())
@click.option("--dry-run", is_flag=True, help="Show what would be moved")
def move(source, dest, dry_run):
"""Move a file or directory."""
if dry_run:
click.echo(f"Would move {source} -> {dest}")
else:
import shutil
shutil.move(source, dest)
click.echo(f"Moved {source} -> {dest}")
if __name__ == "__main__":
cli()
📄 Terminal: calling the subcommands
python file_tool.py --help python file_tool.py list . --ext .py python file_tool.py move old.txt archive/ --dry-run
▶ Output
$ python file_tool.py --help Usage: file_tool.py [OPTIONS] COMMAND [ARGS]... A file management tool. Options: --help Show this message and exit. Commands: list List files in a directory. move Move a file or directory. $ python file_tool.py list . --ext .py file_tool.py 1,086 bytes greet_argparse.py 510 bytes greet_click.py 463 bytes $ python file_tool.py move old.txt archive/ --dry-run Would move old.txt -> archive/
What happened here: @click.group() turned cli into a hub, and each @cli.command() registered an action under it. click read the docstring of every command ("List files in a directory.") and used it as the one-line description on the help screen, for free. The --help output even lists the available subcommands, so a new user can explore the tool without reading any docs. Two things to flag in this code. First, naming the function list shadows Python’s built-in list() inside that function, which works but is a habit worth avoiding; a name like list_files is safer.
Second, type=click.Path(exists=True) makes click reject a directory that does not exist before your code ever runs, so you never have to write that check yourself. The exact byte sizes will differ on your machine since they depend on your files.
Validating Input
The best part of a parser is the work you never have to write. When you say a value should be an integer, both Python argparse and click check it for you and print a clear error if the user types nonsense. No try/except, no manual messages. Watch what happens when someone passes the word “twenty” where a number belongs.
📄 validate_demo.py: argparse rejects a bad integer
import argparse
parser = argparse.ArgumentParser(prog="age.py")
parser.add_argument("age", type=int, help="Your age in years")
args = parser.parse_args(["twenty"]) # not a number on purpose
print(args.age)
▶ Output (and the process exits with code 2)
usage: age.py [-h] age age.py: error: argument age: invalid int value: 'twenty'
What happened here: argparse tried to turn "twenty" into an integer, failed, printed a short usage line plus a clear error, and stopped the program with exit code 2. The print(args.age) line never ran. That exit code matters: in a shell script or Continuous Integration (CI) pipeline, a non-zero exit code signals failure, so other tools know the command did not succeed. click does the same thing with a slightly different message (for the equivalent click.argument it prints Error: Invalid value for 'AGE': 'twenty' is not a valid integer.). Either way, you got input validation without writing a single check. Think of it like a bouncer at a door: bad input never gets inside your function.
Progress Bars and Feedback
When a tool does slow work, like scanning a big folder or downloading files, users want to know it is still alive. It is the same reason a delivery app shows the rider moving on a map: your veg pizza does not arrive any faster, but you relax because you can see progress. click has a progress bar built right in. argparse has nothing like this, which is one of the everyday reasons people reach for click.
📄 progress_demo.py: a built-in progress bar
import click
import time
@click.command()
@click.argument("count", type=int)
def process(count):
"""Process items with a progress bar."""
with click.progressbar(range(count), label="Processing") as bar:
for item in bar:
time.sleep(0.05) # Simulate work
click.secho("Done!", fg="green", bold=True)
if __name__ == "__main__":
process()
▶ Output (in a real terminal, the bar fills up live, then settles like this)
Processing [####################################] 100% Done!
What happened here: click.progressbar() wrapped our range(count) and redrew the bar on every loop step, so a long job feels responsive instead of frozen. The time.sleep(0.05) just stands in for real work like reading a file or calling an Application Programming Interface (API). At the end, click.secho("Done!", fg="green", bold=True) printed a bold green message; secho is echo with style, and click strips the colour automatically when the output is not a terminal, so piped logs stay clean.
Testing Your CLI
A tool you cannot test is a tool you are afraid to change. This is where click really pulls ahead. It ships a CliRunner that works like a flight simulator: it exercises the real controls of your command in-process, captures whatever it printed, and hands you the exit code, all without spawning a real terminal. You can drop these straight into pytest tests.
📄 test_runner.py: call a click command and capture its output
from click.testing import CliRunner
from greet_click import greet
runner = CliRunner()
result = runner.invoke(greet, ["Aditi", "--count", "2"])
print("Exit code:", result.exit_code)
print("Output:")
print(result.output, end="")
▶ Output
Exit code: 0 Output: Hello, Aditi! Hello, Aditi!
What happened here: runner.invoke() ran the greet command with the arguments ["Aditi", "--count", "2"], exactly as if a user named Aditi typed them in a terminal, then collected the result. result.exit_code of 0 means success, and result.output holds every line the command printed. In a real test you would assert on these, for example assert result.exit_code == 0 and assert "Aditi" in result.output. Doing the same for an argparse script means juggling sys.argv and capturing stdout yourself, which is fiddly and easy to get wrong. Easy testing is a big reason teams pick click for tools that will live a long time.
argparse vs click
You have now seen Python argparse and click do the same jobs. Here is the short version of where each one shines, so you can decide at a glance.
| Feature | argparse | click |
|---|---|---|
| Built-in? | Yes | No (pip install) |
| Syntax | Method chaining | Decorators |
| Subcommands | Verbose (add_subparsers) | Clean (@group + @command) |
| Progress bars | No | Yes (built-in) |
| Testing | Harder (sys.argv mocking) | Easy (CliRunner) |
Common Mistakes
Mistake 1: Forgetting that the terminal always sends strings
🚫 Wrong
parser.add_argument("count") # no type, so count stays a string
args = parser.parse_args(["3"])
total = args.count * 2 # "33", not 6
✅ Correct
parser.add_argument("count", type=int) # convert to int up front
args = parser.parse_args(["3"])
total = args.count * 2 # 6
Why: Everything that arrives from the command line starts life as a string, even 3. Without type=int, args.count is the string "3", so "3" * 2 gives "33" instead of the number 6. Always declare the type you actually want and let the parser convert it.
Mistake 2: Writing your own validation when the parser can do it
🚫 Wrong
@click.option("--path")
def run(path):
import os
if not os.path.exists(path): # manual check, manual error
click.echo("Error: path not found")
raise SystemExit(1)
✅ Correct
@click.option("--path", type=click.Path(exists=True))
def run(path):
... # click rejected a bad path already
Why: Both libraries ship validators for the common cases: integers, floats, file paths, choices from a fixed list, number ranges. click.Path(exists=True) (or argparse’s type= with a custom function) checks the value before your function runs and prints a clean error for free. Hand-rolled checks mean more code, inconsistent messages, and bugs you have to maintain.
Mistake 3: Building a tool with no help text
Why: Skipping the help= string on each argument feels faster, but six months later you (or a teammate) will run --help and stare at a blank, useless screen. The help text is the cheapest documentation you will ever write, and the parser turns it into a polished --help page automatically. Always fill it in. For click, the function’s docstring becomes the command description, so a one-line docstring is doing double duty.
Try It Yourself
Build a CLI file search tool with click. Give it three subcommands: find (search by name pattern), grep (search by content), and stats (file count and total size). Add a --recursive flag, an --extension filter, coloured output with click.secho, and a progress bar for large directory scans. When you are done, write one CliRunner test per subcommand so you can change the tool later without fear.
Conclusion
You built the same greeting tool twice and saw the trade-off up close: Python argparse gives you a solid CLI with zero dependencies, while click trades one pip install for decorators, git-style subcommands, progress bars, coloured output, and painless testing with CliRunner. Along the way you picked up the habits that make any CLI pleasant to use: declare types and let the parser validate for you, write the help text as you go, and respect exit codes so shell scripts and CI pipelines can trust your tool.
Next, your polished CLI leaves your laptop: Dockerizing Python Apps with Dockerfile, Compose, and Multi-Stage Builds shows how to ship it anywhere. And if you want to jump around or revisit earlier topics, browse the full Python + AI/ML tutorial series home.
Frequently Asked Questions
Should I use Python argparse or click?
Use Python argparse for simple scripts where you do not want any extra dependencies, since it is built into the standard library. Reach for click when you need subcommands, progress bars, coloured output, or easy testing. For anything beyond a couple of flags, click is the smoother experience.
Do I need to install argparse?
No. Python argparse is part of the standard library, so it is already on your machine the moment you install Python. You only run pip install for click, which is a third-party package.
How do I make my CLI tool installable with pip?
Add a [project.scripts] entry to your pyproject.toml, for example: my-tool = “my_package.cli:main”. After pip install, users can run my-tool from anywhere. See the packaging tutorial for the full walkthrough.
What is the difference between an argument and an option?
A positional argument is identified by its position and is usually required, like the filename in python app.py data.csv. An option is named with a dash and is usually optional, like –output result.csv. A flag is a special option that is just on or off, like –verbose.
Why does my argparse help screen say options instead of optional arguments?
Python 3.13 changed the help layout, and 3.14 keeps it. The section is now labelled options, and short and long names are shown together as -c, –count COUNT. Older tutorials show optional arguments and a repeated metavar, which no longer matches what your terminal prints.
What about Typer?
Typer, from the creator of FastAPI, lets you define a CLI using type hints instead of decorators, and it is built on top of click under the hood. It is a great pick if you like type-hint-driven code. The ecosystem is smaller than click’s, but it is growing quickly.
Interview Questions on Python argparse and click
These come from real screens and onsites. Practice answering before you read each answer.
Q: Your CI pipeline shows a green tick even though your Python tool was called with a misspelled flag. What do you check first?
Both argparse and click exit with code 2 on a usage error, and CI treats any non-zero exit code as failure, so a green tick means that code never reached the shell. The usual culprit is a broad try/except around main() that swallows SystemExit (or catches Exception, prints the error, and returns normally), so the process ends with code 0. Check the entry point: let the parser’s SystemExit propagate, or log the error and re-raise or call sys.exit() with a non-zero value. Exit codes are the contract between your tool and every script that calls it.
Q: Your pytest suite uses CliRunner, and suddenly every test reports exit_code 2 while the tool works fine when you run it by hand. How do you debug it?
Exit code 2 is a usage error, so start by printing result.output, which contains the exact error message click produced, and inspect result.exception. The common causes are all in the argument list you pass to invoke(): every item must be a string (["--count", 2] fails, ["--count", "2"] works), the order must match what a real user would type, and if your CLI is a group you must invoke the group with the subcommand name first, like runner.invoke(cli, ["list", "."]). Fixing the args list to mirror a real terminal call resolves almost all of these.
Q: How would you build git-style subcommands with plain argparse, and how does the wiring differ from click?
With argparse you call parser.add_subparsers(), then add_parser("list") for each action, add that subparser’s own arguments, and typically bind a handler with set_defaults(func=list_files) so that after parse_args() you dispatch with args.func(args). click replaces all that plumbing with @click.group() on a hub function and @cli.command() on each action, and dispatch happens automatically. The argparse version is noticeably more verbose but keeps you dependency-free, which is why small internal scripts often accept the boilerplate.
Q: Which validation belongs in the parser and which belongs in your function body?
Anything about a single value belongs in the parser: type=int, choices=, click.Path(exists=True), or a custom converter (a function raising argparse.ArgumentTypeError, or a click callback raising click.BadParameter). The parser then rejects bad input before your code runs, with consistent messages and exit code 2. Rules that involve two or more values, like --start must come before --end, cannot be attached to one argument, so check them at the top of your function and report them with parser.error() or by raising click.UsageError so the failure looks identical to built-in validation.
Q: A teammate pipes your click tool into a log file and complains that the progress bar and green “Done!” colour disappeared. Is the tool broken?
No, that is deliberate. click checks whether output is going to an interactive terminal; when it is redirected to a file or another program, click skips the animated bar redraws and strips the ANSI colour codes so logs stay readable instead of filling with escape sequences and thousands of redraw frames. If colour is genuinely wanted in the file, you can force it with click.echo(..., color=True), but the default is almost always what you want.
Q: Why does click ship click.echo when Python already has print?
click.echo handles character encoding consistently across Windows, macOS, and Linux, so a name with non-ASCII characters prints the same everywhere instead of crashing on some Windows consoles. It also strips or applies colour depending on whether output is a terminal, can write to stderr with err=True, and plays nicely with CliRunner, which captures its output cleanly in tests. Plain print works, but echo removes a whole class of platform-specific surprises.
Further reading: the official Python documentation is the authoritative source on this.
Related Posts
Previous: Python: Automating Boring Tasks (Files, PDFs, Excel, Emails)
Next: Python: GUI Programming with Tkinter (Windows, Widgets, Events)
Series Home: Python + AI/ML Tutorial Series

No comment