Spawning child processes with std::process
You are building a CLI tool that needs to invoke git to check the repository state. Or maybe you are writing a media converter that calls ffmpeg to transcode a video. Rust does not include a built-in shell. It does not parse strings like ls -l /tmp and execute them magically. Instead, Rust gives you a precise, safe way to talk to the operating system and ask it to run another program.
std::process::Command is the bridge. It lets you construct a description of a process, set its arguments, configure its environment, and then execute it. You can wait for the result, capture its output, or let it run in the background. This is how Rust integrates with the rest of the system without sacrificing safety or control.
The Command builder pattern
Command uses the builder pattern. You do not run a process immediately. You create a Command instance, configure it step by step, and then call a method to execute it. Think of it like filling out a job application. You write your name, add your skills, list your references, and only then do you submit the form. Command is the form. The execution methods are the submit button.
This separation is intentional. It lets you construct the command in a readable way and reuse parts of the configuration. It also forces you to think about what you are asking the OS to do before you do it.
use std::process::Command;
fn main() {
// Create a Command builder for the 'echo' program.
// This does not run anything yet. It just prepares the description.
let mut cmd = Command::new("echo");
// Add arguments one by one.
// Each call appends to the internal argument list.
cmd.arg("Hello");
cmd.arg("World");
// Execute the command and capture all output.
// This blocks the current thread until the process finishes.
let output = cmd.output().expect("Failed to run echo");
// stdout is a Vec<u8>. Convert to string for display.
// from_utf8_lossy replaces invalid sequences with the replacement character.
let stdout = String::from_utf8_lossy(&output.stdout);
println!("Result: {}", stdout.trim());
}
Convention aside: Rust developers prefer String::from_utf8_lossy for logging and display because it never panics. If you are processing structured data, use String::from_utf8 and handle the error. Lossy conversion hides bugs in data pipelines.
What happens under the hood
When you call Command::new("program"), Rust creates a builder struct. It stores the program name and an empty list of arguments. Calling .arg("arg") pushes the argument onto that list. Nothing talks to the OS yet.
When you call .output(), several things happen in sequence. The OS creates a new process. On Unix, this is typically a fork followed by an exec. On Windows, it uses CreateProcess. The new process inherits a copy of the environment, the current directory, and file descriptors unless you change them.
The child process runs. Command captures its standard output and standard error into buffers. The parent thread blocks until the child exits. Once the child finishes, Command returns an Output struct containing the captured bytes and the exit status.
The Output struct has three fields: stdout, stderr, and status. The status field tells you how the process ended. Call status.success() to check if the exit code is zero. Call status.code() to get the integer exit code, which returns None if the process was terminated by a signal.
Ah-ha: Command does not invoke a shell. This is a critical difference from Python's subprocess with shell=True or JavaScript's exec. Rust passes the arguments directly to the OS as an array. There is no parsing. No globbing. No variable expansion. No pipes. You get exactly what you ask for. This prevents injection attacks where a malicious argument contains shell metacharacters like ; rm -rf /. The OS receives the arguments safely.
Never pass a shell command string to Command::new. Split it up. The compiler won't save you here; the OS will just fail to find a program named ls -l.
Realistic usage with error handling
Real code needs to handle failures. Spawning a process can fail if the program is not found, permissions are denied, or arguments are invalid. Execution can fail if the child exits with a non-zero status. Good Rust code distinguishes between these cases.
use std::process::Command;
fn run_git_commit(message: &str) -> Result<(), String> {
// Build the command with arguments and working directory.
// current_dir changes where the process starts.
let output = Command::new("git")
.arg("commit")
.arg("-m")
.arg(message)
.current_dir("/path/to/repo")
.output()
.map_err(|e| format!("Failed to spawn git: {}", e))?;
// Check the exit status.
// git returns non-zero on failure and writes details to stderr.
if !output.status.success() {
let err_msg = String::from_utf8_lossy(&output.stderr);
return Err(format!("Git commit failed: {}", err_msg.trim()));
}
Ok(())
}
Convention aside: Use .map_err to convert spawn errors into your application's error type. This keeps error handling consistent. Also, check status.success() before reading output. Some programs write useful data to stdout even on failure, but others write errors to stderr. Always inspect both streams when debugging.
Pitfalls and compiler errors
Passing arguments as a single string
A common mistake is passing multiple arguments as one string.
// BAD: OS looks for a binary named "ls -l"
let _ = Command::new("ls -l").output();
// GOOD: OS runs "ls" with argument "-l"
let _ = Command::new("ls").arg("-l").output();
If you make this mistake, the OS returns an error like "No such file or directory". The compiler does not catch this. It is a runtime failure. Split your arguments. Each logical argument gets its own .arg() call.
Blocking the thread
output() blocks the current thread. If the child process hangs, your program hangs. If you are running a long-lived process, output() is the wrong tool. Use spawn() instead.
spawn() returns a Child handle. It does not wait. You can interact with the child while it runs. You must eventually wait on the child to reap the exit status. If you drop the Child handle without waiting, Rust waits for you in the destructor. This prevents zombie processes, but it is better to wait explicitly so you can handle the result.
Capturing too much output
output() captures all stdout and stderr into memory. If the child produces gigabytes of output, your program will run out of memory. For large outputs, use spawn() and read the streams incrementally. Or redirect output to a file.
Ah-ha: Command captures output by default when you use output(). If you do not need the output, use status() instead. It runs the process, discards the output, and returns only the exit status. This saves memory and avoids filling pipes.
Environment variables
You can set environment variables for the child using .env() or .envs(). These override the inherited environment. Be careful with sensitive data. Environment variables are visible to the child and its descendants.
// Set a single variable
Command::new("my_tool").env("API_KEY", "secret123").spawn();
// Set multiple variables from an iterator
let vars = vec![("VAR1", "val1"), ("VAR2", "val2")];
Command::new("my_tool").envs(vars).spawn();
Convention aside: Clear sensitive variables in the child if possible. Or use file descriptors to pass secrets instead of environment variables. Environment variables persist in process trees and can leak in logs.
Decision matrix
Use output() when you need the full stdout and stderr captured and you are okay blocking the current thread until the process finishes. Use spawn() when you need to write to the child's stdin, read its output incrementally, or manage the process lifecycle manually. Use status() when you only care about the exit code and want to discard all output. Use Command::new("program").arg("arg") when you want to avoid shell parsing and prevent injection bugs. Use current_dir() when the child needs to run in a specific directory. Use env() when you need to pass configuration to the child via environment variables.
Pick the method that matches your data flow. If you don't need the output, don't capture it. Capturing output allocates memory and fills buffers; skipping it saves resources.