When Rust needs to talk to the world
You're building a tool that wraps ffmpeg to convert videos, or a script that needs to check the current git branch. You could rewrite the logic in Rust, but sometimes the system already has the tool you need. Calling external programs from Rust feels like shouting into a void: you send a message, wait for a response, and hope the other side didn't crash. std::process::Command is the bridge that turns that shout into a structured conversation.
Command lets you spawn child processes, pass arguments, capture output, and check exit codes. It abstracts away the differences between fork/exec on Unix and CreateProcess on Windows. You write one API, and Rust handles the OS plumbing.
Treat Command as a builder, not a runner. The work starts when you call output() or spawn().
The builder pattern in action
Think of Command like filling out a job ticket for a worker. You write down the program name, the arguments, environment variables, and the working directory. You hand the ticket to the operating system. The OS finds the worker, runs the job, and hands you back a receipt with the results.
The API uses a builder pattern. You chain methods to configure the command. Each method returns the builder, so you can stack calls. The configuration stays inert until you trigger execution. This design prevents accidental runs and lets you reuse configuration.
use std::process::Command;
fn main() {
// Create a builder for the "ls" command.
// Nothing runs yet. This just allocates the builder struct.
let mut cmd = Command::new("ls");
// Add arguments individually.
// Each .arg() call appends to the argument list.
cmd.arg("-l");
cmd.arg("-a");
// Set the working directory for the child process.
// The child starts in this dir, regardless of the parent.
cmd.current_dir("/tmp");
// Execute the command and capture output.
// This blocks the current thread until the command finishes.
let output = cmd.output().expect("Failed to execute ls");
// stdout is a Vec<u8>. Convert to string, replacing invalid UTF-8.
// from_utf8_lossy is the convention for handling binary output safely.
let stdout_str = String::from_utf8_lossy(&output.stdout);
println!("Output: {}", stdout_str);
}
Convention aside: String::from_utf8_lossy is the standard way to handle command output. Command output isn't guaranteed to be valid UTF-8. Using from_utf8 panics on invalid sequences. from_utf8_lossy replaces bad bytes with the replacement character, keeping your program alive.
Check status.success(). Exit codes lie less than output does.
Walking through the execution
When you call Command::new, you get a builder object. This object holds the configuration but does nothing else. Calling .arg() adds to that configuration. The chain stops when you call .output().
At that point, Rust asks the OS to create a child process. Your Rust program pauses and waits. The child runs, writes to its standard streams, and exits. The OS captures those streams into buffers and returns them to you.
The output() method returns a Result<Output, io::Error>. The error case happens if the executable cannot be found or the OS refuses to spawn the process. If the executable runs but fails, you get a success result with a non-zero exit code. This distinction matters. A missing binary is a configuration error. A failed binary is a runtime error.
The Output struct contains three fields:
status: The exit status of the process.stdout: The captured standard output as bytes.stderr: The captured standard error as bytes.
You must check status.success() to determine if the command succeeded. Relying on empty output is fragile. Some commands print nothing on success and nothing on failure. The exit code is the source of truth.
Read the streams or discard them. A full pipe is a silent deadlock waiting to happen.
Real-world usage: Git wrapper
Real code needs robust error handling. You want to distinguish between "git is not installed" and "git command failed". You also want to report meaningful errors to the user.
use std::process::Command;
use std::io;
/// Runs git status and prints the result.
/// Returns an error if git is missing or the command fails.
fn run_git_status() -> Result<(), io::Error> {
// Build the command configuration.
let output = Command::new("git")
.arg("status")
.arg("--short")
// Capture output and wait for completion.
// The ? operator propagates io::Error if the command can't run.
.output()?;
// Check if the command actually succeeded.
// A non-zero exit code means the command failed.
if !output.status.success() {
// Convert stderr to string for the error message.
// This helps the user understand why git failed.
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(io::Error::new(
io::ErrorKind::Other,
format!("git failed with code {:?}: {}", output.status.code(), stderr),
));
}
// Process stdout only if success.
let stdout = String::from_utf8_lossy(&output.stdout);
println!("Git status:\n{}", stdout);
Ok(())
}
fn main() {
if let Err(e) = run_git_status() {
eprintln!("Error: {}", e);
}
}
Convention aside: output.status.success() is the idiomatic way to check exit codes. Don't check code == Some(0) manually. The success() method handles platform-specific nuances and is clearer to readers.
Convention aside: io::ErrorKind::Other is appropriate for command failures. If you need more granularity, you can define a custom error enum. The standard library doesn't provide a specific error kind for external command failures.
Trust the separation. Command keeps your Rust code safe from shell injection by default.
Streaming and spawn
output() buffers the entire stdout and stderr in memory. This works fine for small commands. It crashes your program if the command produces gigabytes of output. For long-running processes or large output, use spawn().
spawn() starts the process and returns immediately. You get a Child handle that lets you interact with the process. You can read stdout incrementally, write to stdin, or kill the process.
use std::process::{Command, Stdio};
use std::io::{BufRead, BufReader};
/// Streams output from a command line by line.
fn stream_find() {
// Spawn the process with stdout piped to the parent.
// Stdio::piped creates a pipe for reading.
let mut child = Command::new("find")
.arg("/")
.arg("-name")
.arg("*.rs")
.stdout(Stdio::piped())
// Spawn returns immediately. The child runs in parallel.
.spawn()
.expect("Failed to spawn find");
// Take ownership of the stdout handle from the child.
// This is required to read from the pipe.
if let Some(stdout) = child.stdout {
// Wrap in BufReader for efficient line-by-line reading.
let reader = BufReader::new(stdout);
for line in reader.lines() {
match line {
Ok(path) => println!("Found: {}", path),
Err(e) => eprintln!("Read error: {}", e),
}
}
}
// Wait for the process to finish and check status.
// This prevents zombie processes on Unix.
let status = child.wait().expect("Failed to wait");
if !status.success() {
eprintln!("Command failed");
}
}
Pitfall: Deadlocks. If you spawn a process and don't read its stdout, the OS pipe buffer fills up. The child process tries to write, hits the full buffer, and blocks. The parent waits for the child to finish. The child waits for the parent to read. Deadlock. Always read the streams or discard them with Stdio::null().
Convention aside: child.wait() is mandatory after spawn(). On Unix, failing to wait creates a zombie process. On Windows, it leaks resources. Always wait, even if you don't care about the exit code.
Convention aside: Stdio::piped() is required for reading or writing. The default is Stdio::inherit, which connects the child's streams directly to the parent's terminal. Use piped() when you need to process the data in Rust.
Pick the method that matches your data flow. Buffering everything is easy until the output grows.
Pitfalls and conventions
No shell by default. Command does not invoke a shell. If you pass ls -l as a single argument, it looks for a binary named ls -l. You must split arguments. Use .arg("ls").arg("-l"). If you need shell features like globbing, pipes, or variable expansion, invoke the shell explicitly: Command::new("sh").arg("-c").arg("ls -l *.txt"). This is a safety feature. It prevents shell injection attacks where user input could execute arbitrary commands.
OsStr vs String. Command::new and .arg accept AsRef<OsStr>. This allows &str, String, and OsString. File systems don't require UTF-8. On Unix, filenames are arbitrary bytes. On Windows, they are UTF-16. OsStr abstracts this. If you try to pass a type that doesn't implement AsRef<OsStr>, the compiler rejects it with E0277 (trait bound not satisfied). This usually happens when you pass a struct or a custom type. Convert to String or OsString first.
Environment variables. Use .env("KEY", "value") to set a single variable or .envs(iter) for a batch. The child gets a copy of the environment, modified by your changes. Changes in the child never leak back to the parent. This is safe and isolated.
Killing processes. You can kill a spawned child with child.kill(). This sends SIGKILL on Unix or TerminateProcess on Windows. The process dies immediately. It doesn't run cleanup handlers. Use this as a last resort when a child hangs.
Convention aside: sh -c for shell features. When you need globbing or pipes, use sh -c. Don't try to emulate shell behavior in Rust unless you have a specific reason. The shell is optimized for this.
Convention aside: current_dir() for working directory. Use .current_dir(path) to change where the command runs. This is safer than cd && command because it doesn't require a shell and avoids path injection issues.
Trust the borrow checker. It usually has a point.
Decision matrix
Use output() when you need the full stdout and stderr and the command finishes quickly. The entire output fits in memory, and you can wait for the result synchronously.
Use spawn() when you need to interact with the process while it runs. You want to write to stdin, read stdout in chunks, or manage the process lifecycle asynchronously.
Use status() when you only care whether the command succeeded or failed. You don't need the output, and you want the simplest call that blocks until completion.
Use Command::new("sh").arg("-c").arg("...") when you need shell features like globbing, pipes, or variable expansion. The default Command behavior does not invoke a shell, so these features won't work otherwise.
Use current_dir() when the command depends on a specific working directory. This avoids shell cd commands and keeps the execution context explicit.
Use env() when the child process needs specific environment variables. This isolates the child's environment from the parent and prevents side effects.