Build Scripts (build.rs)
Some things a crate needs can't be known until build time: the path to a system library, a generated Rust file from a .proto schema, the current git hash to bake into a --version string. Procedural Macros generate code during compilation; a build script runs before it. A build.rs in your crate root is an ordinary Rust program that Cargo compiles and runs first, and which talks back to Cargo by printing specially-formatted lines to stdout. This tutorial covers what build scripts are for, the directive protocol, the OUT_DIR codegen pattern, and the rebuild-tracking gotcha that bites everyone once.
What a Build Script Is
Place a file named build.rs next to Cargo.toml, and Cargo automatically compiles and runs it before building the crate itself. It's not magic, it's a normal Rust binary with a main(), and its job is to do setup work and then communicate results back to Cargo via println! lines that start with cargo:.
// build.rs — runs before the crate compiles
fn main() {
// do build-time work here, then emit directives Cargo understands
println!("cargo:rustc-env=BUILD_TIME={}", "2026-06-30");
}
// in the crate, the value is available at compile time:
const BUILD_TIME: &str = env!("BUILD_TIME");
The three things build scripts are for: (1) generating Rust source to be include!d, (2) compiling and linking native (C/C++) code for FFI, and (3) setting compile-time config (env vars, cfg flags) based on the build environment. If your task isn't one of those, you probably don't need a build script.
Talking to Cargo: The cargo: Directives
A build script's stdout is a control channel. Lines beginning with cargo: are directives Cargo interprets; everything else is just logged. The handful you'll actually use:
| Directive | Effect |
|---|---|
cargo:rustc-env=KEY=VALUE | sets an env var readable via env!("KEY") in the crate |
cargo:rustc-cfg=NAME | enables #[cfg(NAME)] blocks (custom conditional compilation) |
cargo:rustc-link-lib=NAME | links a native library |
cargo:rustc-link-search=PATH | adds a directory to the library search path |
cargo:warning=MESSAGE | prints a build warning to the terminal |
cargo:rerun-if-changed=PATH | rebuild-tracking (see below) |
fn main() {
// bake the git hash into the binary for a --version string
let hash = std::process::Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_else(|_| "unknown".into());
println!("cargo:rustc-env=GIT_HASH={hash}");
}
This is the canonical "embed build metadata" pattern: the script runs git at build time, and env!("GIT_HASH") in the crate reads the result as a &'static str, no runtime git dependency in the shipped binary.
Code Generation with OUT_DIR
The most powerful use is generating Rust source. Cargo gives the build script a private, per-build output directory via the OUT_DIR environment variable, you write a .rs file there, and the crate pulls it in with include!. This is how prost turns .proto files into Rust structs and how many parser generators work.
// build.rs
use std::{env, fs, path::Path};
fn main() {
let out_dir = env::var("OUT_DIR").unwrap();
let dest = Path::new(&out_dir).join("table.rs");
// generate Rust source however you like — here, a lookup table
let mut code = String::from("pub static SQUARES: [u32; 10] = [");
for i in 0..10u32 { code.push_str(&format!("{}, ", i * i)); }
code.push_str("];");
fs::write(&dest, code).unwrap();
}
// in the crate (e.g. src/lib.rs):
include!(concat!(env!("OUT_DIR"), "/table.rs"));
// SQUARES is now a normal item, generated at build time
Gotcha: write generated files to
OUT_DIR, never intosrc/.OUT_DIRlives undertarget/, so it's git-ignored, cleaned bycargo clean, and isolated per build profile. Generating intosrc/instead pollutes your source tree, risks committing machine-generated files, and causes spurious diffs. Theinclude!(concat!(env!("OUT_DIR"), "/...))incantation is the standard, deliberately awkward-looking way to bridge build-time output into compile-time source.
The Rebuild-Tracking Trap
By default, Cargo re-runs build.rs only when build.rs itself changes, unless you tell it otherwise. This is the single most common build-script bug: you generate code from a schema.json, edit the JSON, rebuild, and your changes don't appear, because Cargo had no reason to think anything the script depends on changed.
fn main() {
// tell Cargo: re-run this script if any of these change
println!("cargo:rerun-if-changed=schema.json");
println!("cargo:rerun-if-changed=build.rs");
generate_from(/* schema.json */);
}
Gotcha: emitting any
cargo:rerun-if-changedline replaces the default "rerun if anything in the package changed" behavior with exactly the set you list. That's a double-edged trap: forget to list an input and edits to it are silently ignored (stale builds); but once you emit one, you must list every input the script reads, or some changes won't trigger a rebuild. The fix is to print arerun-if-changedfor each file (or directory) the script actually depends on, the moment you add abuild.rsthat reads external inputs, audit that every input is tracked.
When You Need One (and When You Don't)
| Need | Build script? |
|---|---|
Generate Rust from a schema/IDL (.proto, grammar) | yes → OUT_DIR + include! |
| Compile/link C code for FFI | yes → cc crate + rustc-link-* |
| Bake in build metadata (git hash, timestamp) | yes → rustc-env |
Enable a cfg based on the target/environment | yes → rustc-cfg |
| Toggle features at compile time by flag | no → Cargo features (Cargo Features) |
| Transform your own source based on its structure | no → a proc macro |
| One-time project scaffolding | no → a separate tool/script |
The dividing line: a build script is for work that depends on the external build environment or produces generated artifacts, things Cargo features and macros can't reach. Build scripts add compile-time cost and a layer of indirection, and they run on every clean build, so don't reach for one when a feature flag or a proc macro already covers the need. Reach for build.rs specifically when you must inspect the host/target, link native code, or generate source from an external input.
Key Takeaways
build.rsin the crate root is a normal Rust program Cargo compiles and runs before the crate; it communicates results by printingcargo:directives to stdout.- Use it for three things: generating Rust source (
OUT_DIR+include!), compiling/linking native code for FFI (rustc-link-lib/rustc-link-search), and setting build-time config (rustc-env,rustc-cfg). Anything else probably doesn't need one. - Write generated files to
OUT_DIR(undertarget/, git-ignored, profile-isolated), never intosrc/; pull them in withinclude!(concat!(env!("OUT_DIR"), "/file.rs")). - Cargo re-runs
build.rsonly when it changes unless you emitcargo:rerun-if-changed=PATH; emitting any such line replaces the default with exactly your list, so track every external input the script reads or edits will be silently ignored. - Choose a build script for environment-dependent or codegen work; use Cargo features for compile-time toggles and proc macros for transforming your own source.