Rust command-line arguments
last modified November 13, 2026
In this article we show how to work with command-line arguments in Rust.
Command-line arguments are the words typed after the program's
name when it is started. The standard library gives access to them through
std::env::args, which is enough for small tools. For real
command-line interfaces with options, help pages, and error messages, the
clap crate is the de facto standard.
When a program is started with Cargo, the arguments for the program come
after a double dash: cargo run -q -- arg1 arg2. Everything
before the -- is meant for Cargo itself.
The examples were tested with Rust 1.97, the 2024 edition, and clap 4.6. For the clap examples, the name of the Cargo project matches the program name that appears in the help output.
Reading the arguments
The env::args function returns an iterator over the
arguments as Strings.
use std::env;
fn main() {
for (i, arg) in env::args().enumerate() {
println!("{i}: {arg}");
}
}
We print each argument with its index.
for (i, arg) in env::args().enumerate() {
The first item is the path of the program as it was invoked, here the
binary built by Cargo. The shell splits the command line on spaces, so the
quoted "b c" arrives as a single argument without the
quotes.
$ cargo run -q -- a "b c" 0: target/debug/args 1: a 2: b c
Collecting the arguments
Usually we skip the program name and collect the rest into a vector.
use std::env;
fn main() {
let args: Vec<String> = env::args().skip(1).collect();
if args.is_empty() {
println!("no arguments given");
return;
}
println!("{} arguments", args.len());
for arg in &args {
println!("{arg:?} has {} chars", arg.chars().count());
}
}
The skip(1) call drops the program path.
let args: Vec<String> = env::args().skip(1).collect();
if args.is_empty() {
println!("no arguments given");
return;
}
A program must be prepared for missing arguments. Indexing with
args[0] on an empty vector would panic.
$ cargo run -q no arguments given $ cargo run -q -- x "héllo w" 2 arguments "x" has 1 chars "héllo w" has 7 chars
Parsing numbers and exit codes
Arguments are always strings. Numbers have to be parsed, and invalid input should produce an error message and a non-zero exit code.
use std::env;
use std::process;
fn main() {
let args: Vec<String> = env::args().skip(1).collect();
if args.is_empty() {
eprintln!("usage: sum NUMBER...");
process::exit(2);
}
let mut total = 0.0;
for arg in &args {
match arg.parse::<f64>() {
Ok(n) => total += n,
Err(e) => {
eprintln!("sum: invalid number {arg:?}: {e}");
process::exit(1);
}
}
}
println!("{total}");
}
The program adds up the numbers given on the command line.
if args.is_empty() {
eprintln!("usage: sum NUMBER...");
process::exit(2);
}
Error messages go to standard error with eprintln!, so they
do not mix with the output when it is redirected. By convention, exit code
2 means wrong usage.
Err(e) => {
eprintln!("sum: invalid number {arg:?}: {e}");
process::exit(1);
}
A value that is not a number ends the program with exit code 1. The
process::exit function terminates immediately, without
running destructors of values on the stack.
$ cargo run -q -- 1 2.5 3 6.5 $ cargo run -q -- 1 x 3 sum: invalid number "x": invalid float literal $ echo $? 1 $ cargo run -q usage: sum NUMBER... $ echo $? 2
Matching on arguments
Slice patterns are a concise way to handle simple subcommands.
use std::env;
fn main() {
let args: Vec<String> = env::args().skip(1).collect();
let args: Vec<&str> = args.iter().map(String::as_str).collect();
match args.as_slice() {
[] | ["help"] => println!("commands: greet NAME, add A B, upper WORD..."),
["greet", name] => println!("Hello, {name}!"),
["add", a, b] => match (a.parse::<i64>(), b.parse::<i64>()) {
(Ok(a), Ok(b)) => println!("{a} + {b} = {}", a + b),
_ => eprintln!("add: both arguments must be integers"),
},
["upper", words @ ..] if !words.is_empty() => {
println!("{}", words.join(" ").to_uppercase());
}
[cmd, ..] => eprintln!("unknown command or wrong arguments: {cmd}"),
}
}
To match string literals, we convert the Strings into a
vector of &str.
match args.as_slice() {
[] | ["help"] => println!("commands: greet NAME, add A B, upper WORD..."),
["greet", name] => println!("Hello, {name}!"),
Each arm describes one valid command line: its length and fixed words.
The arm for greet matches only when exactly one name follows.
["upper", words @ ..] if !words.is_empty() => {
The .. rest pattern binds the remaining arguments as a
slice. The last arm catches everything else.
$ cargo run -q -- greet Lucia Hello, Lucia! $ cargo run -q -- add 2 40 2 + 40 = 42 $ cargo run -q -- upper rust is fast RUST IS FAST $ cargo run -q -- greet unknown command or wrong arguments: greet $ cargo run -q -- help commands: greet NAME, add A B, upper WORD...
Parsing options by hand
Options such as -v or -n 3 can be parsed with a
loop over the argument iterator.
use std::env;
use std::process;
#[derive(Debug, Default)]
struct Config {
verbose: bool,
count: u32,
name: Option<String>,
files: Vec<String>,
}
fn parse() -> Result<Config, String> {
let mut cfg = Config { count: 1, ..Default::default() };
let mut args = env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"-v" | "--verbose" => cfg.verbose = true,
"-n" | "--count" => {
let v = args.next().ok_or("-n needs a value")?;
cfg.count = v.parse().map_err(|_| format!("bad count: {v}"))?;
}
"--" => {
cfg.files.extend(args.by_ref());
}
s if s.starts_with("--name=") => cfg.name = Some(s["--name=".len()..].to_string()),
s if s.starts_with('-') => return Err(format!("unknown option: {s}")),
_ => cfg.files.push(arg),
}
}
Ok(cfg)
}
fn main() {
match parse() {
Ok(cfg) => println!("{cfg:?}"),
Err(e) => {
eprintln!("error: {e}");
process::exit(2);
}
}
}
The parse function fills a Config struct and
returns an error for invalid input.
"-n" | "--count" => {
let v = args.next().ok_or("-n needs a value")?;
An option that takes a value pulls the next argument from the same iterator, so the value is not treated as a separate argument.
"--" => {
cfg.files.extend(args.by_ref());
}
A double dash ends the options. Everything after it is taken as a file
name, even if it starts with a dash. The by_ref method lets
extend consume the iterator that the loop also uses.
s if s.starts_with("--name=") => cfg.name = Some(s["--name=".len()..].to_string()),
Guards handle the --name=value form and reject unknown
options. Even this small parser has many cases; combined short flags such
as -vn3 or help text would add many more. That is the job of
clap.
$ cargo run -q -- -v -n 3 --name=report a.txt -- -b.txt
Config { verbose: true, count: 3, name: Some("report"), files: ["a.txt", "-b.txt"] }
$ cargo run -q -- -n
error: -n needs a value
$ echo $?
2
$ cargo run -q -- -x
error: unknown option: -x
$ echo $?
2
Non-UTF-8 arguments
On Unix, arguments are arbitrary bytes. env::args panics if
an argument is not valid UTF-8; env::args_os does not.
use std::env;
fn main() {
for arg in env::args_os().skip(1) {
match arg.to_str() {
Some(s) => println!("valid UTF-8: {s}"),
None => println!("not UTF-8: {}", arg.to_string_lossy()),
}
}
let args: Vec<String> = env::args().skip(1).collect();
println!("{args:?}");
}
We pass an argument that contains the byte 0xFF, which never
occurs in UTF-8. The shell's printf produces it from the
octal escape \377.
for arg in env::args_os().skip(1) {
match arg.to_str() {
The args_os function returns OsString values.
The to_str method returns None when the value is
not valid Unicode, and to_string_lossy replaces the invalid
bytes with the replacement character.
let args: Vec<String> = env::args().skip(1).collect();
The second loop uses env::args, which panics on the same
argument. Programs that take file names should use args_os
and convert the values to PathBuf, because file names do not
have to be valid UTF-8.
$ cargo run -q -- a "$(printf 'b\377')" valid UTF-8: a not UTF-8: b� thread 'main' (143302) panicked at /rustc/8bab26f4f68e0e26f0bb7960be334d5b520ea452/library/std/src/env.rs:876:51: called `Result::unwrap()` on an `Err` value: "b\xFF" note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace $ echo $? 101
The clap crate
The clap crate generates a parser from a struct definition. We add it with
the derive feature: cargo add clap --features
derive.
[package]
name = "greet"
version = "0.1.0"
edition = "2024"
[dependencies]
clap = { version = "4.6.7", features = ["derive"] }
The derive feature enables the Parser derive
macro and the #[arg] and #[command]
attributes. The project is named after the first program,
greet.
A first clap program
Each field of the struct becomes an argument.
use clap::Parser;
#[derive(Parser)]
struct Cli {
name: String,
#[arg(short, long, default_value_t = 1)]
count: u8,
}
fn main() {
let cli = Cli::parse();
for _ in 0..cli.count {
println!("Hello, {}!", cli.name);
}
}
A field without attributes is a positional argument. Because its type is
String and not Option<String>, it is
required.
#[arg(short, long, default_value_t = 1)] count: u8,
The short and long attributes turn the field into
the options -c and --count, named after the
field. The value is parsed into a u8, and the default is used
when the option is missing.
let cli = Cli::parse();
The parse method reads env::args_os. On an error,
or when help is requested, it prints a message and exits the process;
errors use exit code 2. Help is generated automatically.
$ cargo run -q -- Anna Hello, Anna! $ cargo run -q -- -c 2 Anna Hello, Anna! Hello, Anna! $ cargo run -q -- --count=3 Boris Hello, Boris! Hello, Boris! Hello, Boris! $ cargo run -q -- --help Usage: greet [OPTIONS] <NAME> Arguments: <NAME> Options: -c, --count <COUNT> [default: 1] -h, --help Print help $ cargo run -q error: the following required arguments were not provided: <NAME> Usage: greet <NAME> For more information, try '--help'. $ echo $? 2 $ cargo run -q -- Anna -c 300 error: invalid value '300' for '--count <COUNT>': 300 is not in 0..=255 For more information, try '--help'. $ echo $? 2
Help text and validation
Doc comments become help text, and value parsers validate input before our code runs.
use clap::Parser;
use std::path::PathBuf;
/// Resize an image to the given width
#[derive(Parser, Debug)]
#[command(name = "resize", version, about)]
struct Cli {
/// Input image file
input: PathBuf,
/// Output file; defaults to the input name with a suffix
#[arg(short, long)]
output: Option<PathBuf>,
/// New width in pixels
#[arg(short, long, value_parser = clap::value_parser!(u32).range(16..=8192))]
width: u32,
/// JPEG quality
#[arg(short, long, default_value_t = 85)]
quality: u8,
}
fn main() {
let cli = Cli::parse();
let output = cli.output.clone().unwrap_or_else(|| {
let stem = cli.input.file_stem().unwrap_or_default().to_string_lossy();
cli.input.with_file_name(format!("{stem}-{}.jpg", cli.width))
});
println!("{} -> {} (width {}, quality {})", cli.input.display(), output.display(), cli.width, cli.quality);
}
The program pretends to resize an image; it only computes and prints the settings.
/// Resize an image to the given width #[derive(Parser, Debug)] #[command(name = "resize", version, about)]
The doc comment on the struct is the description of the program, and the
about attribute puts it into the help. The
version attribute adds --version with the
version from Cargo.toml.
#[arg(short, long, value_parser = clap::value_parser!(u32).range(16..=8192))] width: u32,
The width is a required option, because its type is not an
Option. The value parser rejects values outside the range
with a clear message.
input: PathBuf, output: Option<PathBuf>,
Paths are parsed as PathBuf, so non-UTF-8 file names work. An
Option field is an optional argument.
$ cargo run -q -- --help Resize an image to the given width Usage: resize [OPTIONS] --width <WIDTH> <INPUT> Arguments: <INPUT> Input image file Options: -o, --output <OUTPUT> Output file; defaults to the input name with a suffix -w, --width <WIDTH> New width in pixels -q, --quality <QUALITY> JPEG quality [default: 85] -h, --help Print help -V, --version Print version $ cargo run -q -- --version resize 0.1.0 $ cargo run -q -- photo.png -w 800 photo.png -> photo-800.jpg (width 800, quality 85) $ cargo run -q -- photo.png -w 800 -o small.jpg -q 70 photo.png -> small.jpg (width 800, quality 70) $ cargo run -q -- photo.png -w 10 error: invalid value '10' for '--width <WIDTH>': 10 is not in 16..=8192 For more information, try '--help'. $ echo $? 2 $ cargo run -q -- photo.png error: the following required arguments were not provided: --width <WIDTH> Usage: resize --width <WIDTH> <INPUT> For more information, try '--help'. $ echo $? 2
Flags
A bool field is a flag that takes no value. Other actions
count repeated flags or turn a default off.
use clap::{ArgAction, Parser};
#[derive(Parser)]
#[command(name = "verbose")]
struct Cli {
#[arg(short, long, action = ArgAction::Count)]
verbose: u8,
#[arg(short, long)]
quiet: bool,
#[arg(long = "no-color", action = ArgAction::SetFalse)]
color: bool,
}
fn main() {
let cli = Cli::parse();
let level = match (cli.quiet, cli.verbose) {
(true, _) => "error",
(false, 0) => "warn",
(false, 1) => "info",
(false, 2) => "debug",
_ => "trace",
};
println!("log level: {level}, color: {}", cli.color);
}
The verbosity level grows with each -v.
#[arg(short, long, action = ArgAction::Count)] verbose: u8,
ArgAction::Count counts the occurrences, so
-v -v and -vv both give 2. Short flags can be
combined behind one dash.
#[arg(long = "no-color", action = ArgAction::SetFalse)] color: bool,
With SetFalse the field is true by default and
the --no-color flag sets it to false.
$ cargo run -q log level: warn, color: true $ cargo run -q -- -v log level: info, color: true $ cargo run -q -- -vvv --no-color log level: trace, color: false $ cargo run -q -- -q -vv log level: error, color: true
Multiple values
A Vec field accepts any number of values.
use clap::Parser;
#[derive(Parser)]
#[command(name = "tags")]
struct Cli {
#[arg(required = true)]
files: Vec<String>,
#[arg(short, long)]
tag: Vec<String>,
#[arg(long, value_delimiter = ',')]
exclude: Vec<String>,
}
fn main() {
let cli = Cli::parse();
println!("files: {:?}", cli.files);
println!("tags: {:?}", cli.tag);
println!("exclude: {:?}", cli.exclude);
}
A positional Vec collects all remaining arguments.
#[arg(required = true)] files: Vec<String>,
A Vec is optional by default and may be empty. Here we
require at least one file.
#[arg(short, long)] tag: Vec<String>, #[arg(long, value_delimiter = ',')] exclude: Vec<String>,
An option of type Vec can be repeated: each occurrence adds
one value. With value_delimiter, one occurrence can also
contain several comma-separated values.
$ cargo run -q -- a.txt b.txt -t rust --tag cli --exclude tmp,bak files: ["a.txt", "b.txt"] tags: ["rust", "cli"] exclude: ["tmp", "bak"] $ cargo run -q -- a.txt files: ["a.txt"] tags: [] exclude: [] $ cargo run -q error: the following required arguments were not provided: <FILES>... Usage: tags <FILES>... For more information, try '--help'. $ echo $? 2
Enumerated values
The ValueEnum derive restricts an option to a fixed set of
values.
use clap::{Parser, ValueEnum};
#[derive(Clone, Copy, Debug, ValueEnum)]
enum Format {
Text,
Csv,
Json,
}
#[derive(Parser)]
#[command(name = "export")]
struct Cli {
#[arg(short, long, value_enum, default_value_t = Format::Text)]
format: Format,
}
fn main() {
let cli = Cli::parse();
let rows = [("Anna", 82), ("Boris", 95)];
match cli.format {
Format::Text => rows.iter().for_each(|(n, s)| println!("{n:<6}{s:>4}")),
Format::Csv => {
println!("name,score");
rows.iter().for_each(|(n, s)| println!("{n},{s}"));
}
Format::Json => {
let items: Vec<String> = rows
.iter()
.map(|(n, s)| format!("{{\"name\":\"{n}\",\"score\":{s}}}"))
.collect();
println!("[{}]", items.join(","));
}
}
}
The output format can be text, CSV, or JSON.
#[derive(Clone, Copy, Debug, ValueEnum)]
enum Format {
Text,
Csv,
Json,
}
The variant names are converted to lowercase values. The help and error messages list the possible values automatically.
match cli.format {
Format::Text => rows.iter().for_each(|(n, s)| println!("{n:<6}{s:>4}")),
In our code we work with a Rust enum instead of comparing strings, and the compiler checks that every format is handled.
$ cargo run -q
Anna 82
Boris 95
$ cargo run -q -- -f csv
name,score
Anna,82
Boris,95
$ cargo run -q -- --format json
[{"name":"Anna","score":82},{"name":"Boris","score":95}]
$ cargo run -q -- -f yaml
error: invalid value 'yaml' for '--format <FORMAT>'
[possible values: text, csv, json]
For more information, try '--help'.
$ echo $?
2
Subcommands
Tools like git or cargo have subcommands, each
with its own arguments. Clap derives them from an enum.
use clap::{Parser, Subcommand};
/// A minimal task manager
#[derive(Parser)]
#[command(name = "todo", version, about)]
struct Cli {
/// Use this task list instead of the default one
#[arg(short, long, global = true, default_value = "tasks.txt")]
list: String,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Add a new task
Add {
text: Vec<String>,
#[arg(short, long, default_value_t = 2)]
priority: u8,
},
/// List tasks
#[command(alias = "ls")]
List {
#[arg(long)]
all: bool,
},
/// Mark a task as done
Done { id: u32 },
}
fn main() {
let cli = Cli::parse();
match cli.command {
Command::Add { text, priority } => {
println!("[{}] add {:?} with priority {priority}", cli.list, text.join(" "));
}
Command::List { all } => {
println!("[{}] list {} tasks", cli.list, if all { "all" } else { "open" });
}
Command::Done { id } => println!("[{}] task {id} done", cli.list),
}
}
Each variant of the Command enum is a subcommand, and its
fields are the subcommand's arguments.
#[command(subcommand)] command: Command,
The subcommand attribute connects the enum to the main
struct. Since the field is not an Option, a subcommand is
required.
#[arg(short, long, global = true, default_value = "tasks.txt")] list: String,
A global option can be given before or after the subcommand.
/// List tasks
#[command(alias = "ls")]
List {
The variant List becomes the subcommand list,
with ls as a hidden alias. Clap also adds a
help subcommand.
$ cargo run -q -- --help A minimal task manager Usage: todo [OPTIONS] <COMMAND> Commands: add Add a new task list List tasks done Mark a task as done help Print this message or the help of the given subcommand(s) Options: -l, --list <LIST> Use this task list instead of the default one [default: tasks.txt] -h, --help Print help -V, --version Print version $ cargo run -q -- add buy milk -p 1 [tasks.txt] add "buy milk" with priority 1 $ cargo run -q -- ls --all -l work.txt [work.txt] list all tasks $ cargo run -q -- done 3 [tasks.txt] task 3 done $ cargo run -q -- done error: the following required arguments were not provided: <ID> Usage: todo done <ID> For more information, try '--help'. $ echo $? 2
The builder API
Besides the derive API, clap has a builder API that constructs the parser at run time.
use clap::{Arg, ArgAction, Command, value_parser};
fn main() {
let matches = Command::new("builder")
.about("Repeats a word")
.arg(Arg::new("word").required(true))
.arg(
Arg::new("times")
.short('t')
.long("times")
.value_parser(value_parser!(usize))
.default_value("2"),
)
.arg(Arg::new("upper").short('u').action(ArgAction::SetTrue))
.get_matches();
let word = matches.get_one::<String>("word").unwrap();
let times = *matches.get_one::<usize>("times").unwrap();
let upper = matches.get_flag("upper");
let w = if upper { word.to_uppercase() } else { word.clone() };
println!("{}", vec![w; times].join(" "));
}
Arguments are defined with Arg::new and configured with
method calls.
Arg::new("times")
.short('t')
.long("times")
.value_parser(value_parser!(usize))
.default_value("2"),
The value parser converts the string into a usize; the
default is given as a string and parsed the same way.
let times = *matches.get_one::<usize>("times").unwrap();
let upper = matches.get_flag("upper");
Values are looked up by their ID. The type in get_one must
match the value parser, which is checked only at run time; the derive API
avoids this. The builder API is useful when the arguments are not known
at compile time.
$ cargo run -q -- hello hello hello $ cargo run -q -- hello -t 3 -u HELLO HELLO HELLO
Source
The Rust Programming Language: Accepting Command Line Arguments
In this article we have worked with command-line arguments in Rust. Cargo itself is covered in Rust Cargo, and parsing strings in Rust type conversion.
Author
List all Rust tutorials.