When code needs to write code
You're building a CLI tool and you want the version string to include the current git commit hash. You try to read the git repository at runtime, but that adds latency and fails if the binary moves away from the repo. You try to hardcode the hash, but you forget to update it every time you push. You need the compiler to do the work for you, injecting data right into your binary before it even exists.
That's where build.rs lives. Cargo runs this script as a separate step before compiling your source code. It can generate Rust files, compile C libraries, detect system features, or whisper instructions to the compiler via environment variables. When you run cargo build, Cargo sees build.rs, compiles and runs it, captures its output, and then uses that output to configure the rest of the build.
The build script pipeline
Think of build.rs as a pre-flight checklist for your compilation. Cargo doesn't just compile src/main.rs. It notices build.rs exists and inserts a new stage into the build graph. Cargo compiles build.rs into a temporary executable and runs it. Your script runs in a sandbox. It gets access to OUT_DIR, a temporary folder where you can dump generated files. It can also print special lines starting with cargo:. Cargo watches stdout for these lines. If you print cargo:rerun-if-changed=build.rs, Cargo records that dependency. If you print cargo:rustc-env=VERSION=1.0, Cargo sets an environment variable for the rest of the build. Once your script exits, Cargo uses all that information to compile your actual source code. The generated files in OUT_DIR become part of the build graph.
Minimal example: generating a constant
The simplest use case is generating a file that defines a constant. This avoids hardcoding values and keeps them in sync with external data.
// build.rs
use std::env;
use std::fs;
use std::path::Path;
fn main() {
// OUT_DIR is set by Cargo; it's the scratchpad for build artifacts.
// Files written here are available to the rest of the build.
let out_dir = env::var("OUT_DIR").unwrap();
let dest_path = Path::new(&out_dir).join("version.rs");
// Write a generated file into the output directory.
// This file will be included by the main crate.
fs::write(&dest_path, "pub const VERSION: &str = \"1.0.0\";").unwrap();
// Tell Cargo to re-run this script if build.rs changes.
// Without this, Cargo caches the output forever.
println!("cargo:rerun-if-changed=build.rs");
}
In your src/main.rs, include the generated file:
// src/main.rs
// Include the generated file at compile time.
// env!("OUT_DIR") expands to the path set by the build script.
include!(concat!(env!("OUT_DIR"), "/version.rs"));
fn main() {
println!("Version: {}", VERSION);
}
Run cargo run and you'll see Version: 1.0.0. The include! macro reads the file at compile time and injects its contents into your source code. The VERSION constant becomes part of your binary.
The cargo: protocol
Build scripts communicate with Cargo via stdout. Every line that starts with cargo: is a directive. Cargo parses these lines and adjusts the build configuration. Lines that don't start with cargo: are ignored, which is why you should use eprintln! for debug logs. Mixing debug output with directives can break the build.
Common directives include:
cargo:rustc-env=KEY=VALUEsets an environment variable for the compiler. You can access it in Rust code usingenv!("KEY").cargo:rustc-link-lib=static=footells the linker to link against a static library namedlibfoo.a.cargo:rustc-link-search=native=/pathadds a directory to the linker's search path.cargo:rustc-cfg=feature_nameenables a conditional compilation flag. You can use#[cfg(feature_name)]in your code.cargo:rerun-if-changed=pathdeclares a dependency. Cargo re-runs the script if the file changes.cargo:rerun-if-env-changed=VARdeclares an environment variable dependency. Cargo re-runs the script if the variable changes.cargo:warning=messageprints a warning to the user. This is the correct way to show messages. Do not useprintln!for warnings.
Convention aside: cargo:warning is the standard for user-visible messages. It integrates with Cargo's output formatting and can be suppressed with --quiet. If you use println! for warnings, you risk breaking the directive parser or cluttering the output.
Realistic example: injecting a git hash
A common pattern is injecting the git commit hash into the binary. This helps with debugging and versioning.
// build.rs
use std::process::Command;
fn main() {
// Only run git commands if we're in a git repo.
// This prevents build failures in non-git environments.
if let Ok(output) = Command::new("git").arg("rev-parse").arg("HEAD").output() {
if output.status.success() {
let hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
// Inject the hash as an environment variable for the compiler.
println!("cargo:rustc-env=GIT_HASH={}", hash);
}
}
// Re-run if build.rs changes or if .git/HEAD changes.
// This ensures the hash updates when you commit.
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=.git/HEAD");
}
In src/main.rs:
// src/main.rs
fn main() {
// Access the env var set by build.rs.
// env!() expands at compile time, not runtime.
let hash = env!("GIT_HASH");
println!("Built from commit: {}", hash);
}
This approach bakes the hash into the binary. It's fast and reliable. The env! macro expands to a string literal at compile time. There's no runtime overhead.
Pitfalls and gotchas
Build scripts are powerful, but they come with traps. The first trap is host versus target. Build scripts run on the host machine, not the target. If you're cross-compiling for a 32-bit ARM device, build.rs still runs on your x86 laptop. This matters if you try to link libraries or check system properties. The script must produce artifacts that work for the target, even though it runs on the host. If you use Command::new("pkg-config"), it runs on the host and might find host libraries instead of target libraries. You need to handle this carefully. The cc crate helps here by detecting cross-compilation and adjusting flags automatically.
The second trap is caching. Cargo caches build script outputs aggressively. It hashes the inputs and the output. If the inputs don't change, Cargo reuses the cached output. This is why cargo:rerun-if-changed is mandatory. If you forget it, Cargo assumes the script output never changes. You'll get stale generated code until you run cargo clean. The compiler won't warn you. Your binary will just lie. Treat rerun-if-changed as a contract. If you skip it, your cache is a lie.
The third trap is isolation. Build scripts are separate crates. They don't see your src/ dependencies. If you need a crate in build.rs, put it under [build-dependencies] in Cargo.toml. If you try to use a regular dependency, the compiler rejects it with E0432 (use of undeclared type). This separation keeps build scripts lightweight and prevents accidental coupling.
The fourth trap is performance. Build scripts add overhead. They compile and run. If your build script is slow, your builds are slow. Keep them fast. Avoid heavy computation. Use rerun-if-changed to skip when possible. If you're generating large files, consider caching the output externally. Every millisecond adds up in CI.
Convention aside: build.rs should be idempotent. Running it twice should produce the same result. This makes debugging easier and prevents subtle bugs. Also, OUT_DIR is a temp directory. It gets cleaned on cargo clean. Don't commit files there. Use CARGO_MANIFEST_DIR if you need to read files from the project root.
Decision: build.rs vs alternatives
Use build.rs when you need to run external tools like protoc or gcc to generate artifacts before compilation. Use build.rs when you need to inject compile-time constants like git hashes or build timestamps into your binary. Use build.rs when you are wrapping a C library and need to configure the linker flags or header paths dynamically. Reach for const fn when the value can be computed from other constants without side effects. Reach for procedural macros when you need to inspect and transform Rust code syntax rather than generate independent files. Reach for runtime configuration when the value changes per deployment and doesn't need to be baked into the binary.
Build scripts are the right tool for compile-time generation and system integration. They bridge the gap between Rust's safety and the messy reality of external tools and libraries. Use them wisely, declare your dependencies, and keep them fast.