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.
What a bare name resolves to
Section titled “What a bare name resolves to”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:
command -v gitcommand -v lscommand -v printenv/usr/bin/git/bin/ls/usr/bin/printenvYour 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:
command -v /bin/ls/bin/lsThe check without the run
Section titled “The check without the run”command -v is the portable way to ask “would this name run?”. A name that resolves nowhere prints nothing and exits 1:
command -v definitely-not-a-real-commandecho $?1The 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:
command -v git >/dev/null && echo "git is available"command -v kubectl >/dev/null || echo "kubectl is missing"git is availablekubectl is missingwhich 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.
What a missing name costs at run time
Section titled “What a missing name costs at run time”Skip the check and run the name anyway, and the shell reports the lookup failure:
definitely-not-a-real-commandecho $?bash: definitely-not-a-real-command: command not found127The 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:
touch not-executable./not-executableecho $?bash: ./not-executable: Permission denied126Here 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.
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), }}EOFrustc --edition 2024 spawn.rs./spawnkind: 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.
Walking PATH yourself
Section titled “Walking PATH yourself”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:
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());}EOFrustc --edition 2024 find.rs./findfirst: /usr/bin/gittotal: 1The 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.
Related
Section titled “Related”- How to run an external command and read its output — the spawn that succeeds, and what
.output()hands back - How to set an environment variable for a child process — where the
PATHthe lookup uses comes from - Processes, environment and signals — the rest of the chapter