The wall of text
You run cargo build. The terminal spits out a stack trace. At the bottom, the verdict: error: failed to run custom build command. The build halts. You didn't write a build.rs. You just wanted to compile a crate. The error feels opaque because it is. Cargo is telling you that a script ran before your code compiled, and that script crashed. The script could be in your project, or it could be in a dependency like ring, openssl, or tikv-jemalloc. The fix depends on why the script failed.
What is a build script?
A build.rs file is a Rust program that Cargo compiles and executes before compiling the rest of the crate. Think of it as a pre-compilation hook. The compiler needs information it cannot get from the source code alone. Maybe it needs to know where a C library lives. Maybe it needs to generate bindings for a protocol buffer definition. Maybe it needs to embed a version string from git describe. The build script gathers this information and tells Cargo how to proceed.
The script runs as a separate binary. Cargo compiles build.rs, runs the resulting executable, captures its output, and parses that output for instructions. If the script panics, exits with a non-zero code, or calls a missing external tool, Cargo reports the generic "failed to run custom build command" error. The error message is generic because Cargo only knows the script died. It does not know the script tried to find libssl and failed, or that it tried to run cmake and the command wasn't found. You have to look deeper.
The lifecycle
Cargo follows a strict sequence when it encounters a build.rs.
First, Cargo compiles the build script itself. This uses the host toolchain, not the target toolchain. If you are cross-compiling for Linux from macOS, the build script compiles and runs on macOS. This distinction matters. The script runs on the machine where Cargo is executing, not on the machine where the final binary will run.
Second, Cargo runs the compiled build script. The script can read environment variables, check for system libraries, invoke external tools, and write files to a special output directory. The script communicates back to Cargo by printing lines to standard output. Every line must follow a specific format. Cargo parses these lines to extract linker flags, environment variables, and warnings.
Third, Cargo applies the instructions from the script. If the script printed cargo:rustc-link-lib=ssl, Cargo adds -lssl to the linker command. If the script printed cargo:rustc-env=VERSION=1.0, Cargo sets the VERSION environment variable for the rest of the crate. If the script failed during the second step, Cargo never reaches the third step. The build stops.
Minimal example
A build script is just Rust code. If the code panics, the build fails.
/// A build script that intentionally fails to demonstrate the error.
fn main() {
// This panic causes the build script to exit with an error.
// Cargo catches this and reports "failed to run custom build command".
panic!("Build script crashed!");
}
Run cargo build with this script, and you get the error. The panic message might appear in the output, but the headline is always the same. The script died. The build cannot continue.
Realistic example: missing system dependency
The most common cause of this error is a missing system library. Crates that wrap C code often use pkg-config or vcpkg to find libraries on the user's system. If the library is not installed, the probe fails, and the script panics.
/// Probe for a system library and configure the linker.
fn main() {
// Use pkg-config to find the library.
// This fails if the library is not installed on the system.
let lib = pkg_config::probe("libsqlite3")
.expect("libsqlite3 not found. Install libsqlite3-dev.");
// Tell Cargo to link against the found library.
println!("cargo:rustc-link-lib=sqlite3");
// Tell Cargo to re-run this script if the file changes.
println!("cargo:rerun-if-changed=build.rs");
}
If you run this on a machine without libsqlite3, the script panics with the message in the expect. Cargo reports the failure. The fix is to install the system package. On Debian or Ubuntu, that is sudo apt install libsqlite3-dev. On Fedora, sudo dnf install libsqlite3x-devel. On macOS, brew install sqlite3. The error message from the script often tells you exactly what to install. Read the script output.
Realistic example: the cc crate and C compilers
Many crates use the cc crate to compile C or C++ code as part of the build. The cc crate invokes a C compiler like gcc or clang. If the compiler is missing or misconfigured, the build script fails.
/// Compile a C file using the cc crate.
fn main() {
// Configure the C compiler.
// This fails if no C compiler is found in PATH.
cc::Build::new()
.file("src/helper.c")
.compile("helper");
// Re-run if the C file changes.
println!("cargo:rerun-if-changed=src/helper.c");
}
If you see this error and the crate uses cc, check your C toolchain. On Linux, you might need sudo apt install build-essential. On macOS, you need Xcode Command Line Tools. Run xcode-select --install if clang is missing. On Windows, you need the Visual C++ Build Tools. The cc crate looks for standard compiler names. If it cannot find one, it aborts. The error output usually mentions the missing compiler.
Pitfalls and debugging
The error message hides the real problem. You need to extract the details.
Run cargo build -vv. The -vv flag enables verbose output. Cargo prints the exact command it runs for the build script and the full output of the script. Look for the section labeled Running target/debug/build/.... The error will be there. If the script calls an external tool, the tool's error message appears in this section. If the script panics, the panic message appears here.
Convention aside: build.rs files should declare exactly what triggers a rebuild. Always include println!("cargo:rerun-if-changed=build.rs"); and list any input files. Without this, Cargo might skip the script when it shouldn't, leading to stale artifacts that cause cryptic errors later. The community treats rerun-if-changed as mandatory. If you omit it, your build script is broken by default.
Pitfall: build.rs output is parsed by Cargo. Every line must be a valid Cargo directive or empty. If you use println!("Hello world");, Cargo sees a line it does not understand. It might ignore it, or it might fail. Use println!("cargo:warning=Hello world"); to print messages that survive the parse. This is the only safe way to debug output from a build script. Random println! calls are a recipe for silent failures or parse errors.
Pitfall: build.rs runs on the host machine, not the target. If you are cross-compiling for Linux from macOS, the build script runs on macOS. If the script tries to run a Linux binary, it fails. This is a common trap. Check cfg!(target_os = ...) inside the script to handle host vs target differences. The script should only run tools that exist on the host. If it needs to generate code for the target, it must use the target's toolchain, not the host's binaries.
Pitfall: OUT_DIR is the only place you can write files. The build script runs in a temporary directory. If you try to write to the project root or a random path, you might fail due to permissions, or you might create files that Cargo does not track. Use std::env::var("OUT_DIR") to get the path where Cargo expects output. Write generated code or data files there. The rest of the crate can include files from OUT_DIR using include! or include_str!.
Decision matrix
Build scripts add complexity. They slow down builds, make cross-compilation harder, and introduce failure modes that do not exist in pure Rust code. Use them only when necessary.
Use build.rs when you need to generate code at compile time, such as wrapping C headers with bindgen or processing template files. Use build.rs when you need to probe for system libraries and pass linker flags to the compiler based on what is installed. Use build.rs when you need to set environment variables for the crate based on build configuration or target architecture. Reach for include_str! or include_bytes! when you just need to embed a static file; a build script adds unnecessary complexity and slows down builds. Reach for const evaluation or procedural macros when you can compute values at compile time without running external tools or accessing the filesystem. Avoid build.rs when a pure Rust solution exists; build scripts make cross-compilation harder and break incremental builds if they are not written carefully.