Skip to content
Select theme

How to check whether a command exists

Short answer: In a shell, command -v NAME prints the path the name resolves to and exits 0 — or exits 1 with no output when it resolves to nothing. Command::new(NAME) does the same PATH lookup at spawn time, and a name that resolves nowhere comes back as Err with ErrorKind::NotFound. There is no which in the standard library: the spawn is the check, and the error is the answer.

The previous article ended on a promise: a name that is not on PATH never starts, and the failure comes back as Err, not as a nonzero exit status. This page is about that error — what the shell does with the same name, and what the lookup looks like from Rust.

The blocks below are plain POSIX shell and work in any terminal on macOS or Linux. definitely-not-a-real-command plays the part of a name that resolves to nothing, and so does kubectl — it is simply not installed on the machine these runs come from. Run the blocks in order in one terminal.

A bare command name — ls, not ./ls or /bin/ls — is looked up the same way everywhere: the shell walks the directories in $PATH in order, and the first directory containing an executable file with that name wins. command -v prints the winner without running anything:

Terminal window
command -v git
command -v ls
command -v printenv
/usr/bin/git
/bin/ls
/usr/bin/printenv

Your lines will be your own — they depend on how your PATH is set up, which is exactly the point: the answer is a property of the environment, not of the name. A name containing a slash skips the lookup entirely, and the path is used as given:

Terminal window
command -v /bin/ls
/bin/ls

command -v is the portable way to ask “would this name run?”. A name that resolves nowhere prints nothing and exits 1:

Terminal window
command -v definitely-not-a-real-command
echo $?
1

The empty first line is the check’s entire output — nothing. That is the shape every script uses: route the exit status through && and ||, and the path itself is irrelevant:

Terminal window
command -v git >/dev/null && echo "git is available"
command -v kubectl >/dev/null || echo "kubectl is missing"
git is available
kubectl is missing

which also exists on macOS and Linux, and for a quick look in a terminal it is fine — but it is not POSIX, and its output and exit codes differ between systems. In a script, command -v is the one that behaves the same everywhere.

Skip the check and run the name anyway, and the shell reports the lookup failure:

Terminal window
definitely-not-a-real-command
echo $?
bash: definitely-not-a-real-command: command not found
127

The first word is the shell’s own name, so yours may read zsh or sh, and the exact wording varies with the shell — the exit code does not: 127 is the universal “looked it up on PATH, found nothing”. Compare it with the other way a command can fail to start:

Terminal window
touch not-executable
./not-executable
echo $?
bash: ./not-executable: Permission denied
126

Here the file was found — ./not-executable is right there in the current directory — but it is not executable, so the run never happened: 126. The two codes answer different questions, and 127 is the one that means “not on PATH”.

The same lookup in Rust: the spawn that fails instead

Section titled “The same lookup in Rust: the spawn that fails instead”

Command::new resolves a bare name against PATH when the child spawns. As long as the command starts, everything the previous article said holds — even a nonzero exit arrives in status, not in Err. The Err case is precisely this page’s subject: the lookup failed, and the child never existed.

Terminal window
cat > spawn.rs <<'EOF'
use std::process::Command;
fn main() {
match Command::new("definitely-not-a-real-command").spawn() {
Ok(child) => println!("started, pid {}", child.id()),
Err(e) => println!("kind: {:?}, message: {}", e.kind(), e),
}
}
EOF
rustc --edition 2024 spawn.rs
./spawn
kind: NotFound, message: No such file or directory (os error 2)

ErrorKind::NotFound is the “looked it up, found nothing” case — the same answer the shell’s exit 127 gave. It is also the one case worth a specific message to the user: NotFound means “the tool is not installed or not on PATH”, while any other spawn error means the command was found but could not be run. Every .expect("failed to run echo") in the previous article was a panic on this branch.

And note where the PATH comes from: your process inherited a copy of it at startup, like any other environment variable. A program launched from an IDE or a GUI application did not get the PATH a login shell sets up, so Command::new("cargo") can work from a terminal run and fail with NotFound from the same program launched elsewhere. When that happens, print std::env::var("PATH") from the failing program and compare it with the terminal’s — the missing directory is usually right there in the diff.

The standard library has no which, and usually does not need one. When a report is genuinely the product — “which git will this use?”, “is it on the list?” — env::split_paths walks the lookup yourself:

Terminal window
cat > find.rs <<'EOF'
use std::env;
use std::path::PathBuf;
fn main() {
let name = "git";
let path = env::var_os("PATH").unwrap_or_default();
let hits: Vec<PathBuf> = env::split_paths(&path)
.map(|dir| dir.join(name))
.filter(|p| p.is_file())
.collect();
match hits.first() {
Some(p) => println!("first: {}", p.display()),
None => println!("first: none"),
}
println!("total: {}", hits.len());
}
EOF
rustc --edition 2024 find.rs
./find
first: /usr/bin/git
total: 1

The first hit is the same /usr/bin/git that command -v git printed — the walk reproduces the shell’s search. Two caveats keep it honest: is_file does not check the executable bit, so a hit is a candidate rather than a guarantee; and the real test is still the spawn, whose Err is the truth the walk can only predict. Use the walk to report and to choose between hits; use the spawn to decide.