The Cargo Guide
Build Scripts
Why Build Scripts Exist
A Cargo build script is a Rust program, usually named build.rs, that Cargo runs before compiling the package itself. Build scripts exist for tasks that cannot be expressed purely through normal Rust source code and static manifest declarations.
A useful mental model is:
- normal crate code defines your package logic
build.rsprepares build-time inputs, outputs, and compiler or linker instructions
Typical uses include:
- generating Rust source or data files
- probing the system for native libraries or headers
- compiling bundled native code
- emitting linker or compiler instructions
- communicating build-time metadata to dependent crates
Where build.rs Comes From
Cargo looks for a build script in the package root, with build.rs as the default path.
A small package layout might look like this:
my_crate/
āāā Cargo.toml
āāā build.rs
āāā src/
āāā lib.rsThe manifest can also control the build script path explicitly.
Example:
[package]
name = "build_demo"
version = "0.1.0"
edition = "2024"
build = "build.rs"And a package can disable automatic build-script detection with:
[package]
name = "build_demo"
version = "0.1.0"
edition = "2024"
build = falseWhen Cargo Runs a Build Script
Cargo runs the build script before compiling the crate that owns it. The build script itself is compiled first, then executed, and its output tells Cargo what to do next.
A useful high-level flow is:
1. compile build.rs
2. run build.rs
3. read its emitted directives
4. compile the package using those resultsThis is why build scripts are part of the build graph rather than an unrelated pre-step.
Build Dependencies
If build.rs needs crates of its own, those go in [build-dependencies].
Example:
[package]
name = "native_demo"
version = "0.1.0"
edition = "2024"
[build-dependencies]
cc = "1"This is separate from normal [dependencies] because build scripts run at build time, not as part of the final crate's runtime or library API.
A useful mental model is:
[dependencies]are for your crate code[build-dependencies]are forbuild.rs
A Minimal build.rs
A minimal build script can be as small as this:
fn main() {
println!("cargo:warning=build script ran");
}Or with the newer directive form:
fn main() {
println!("cargo::warning=build script ran");
}The cargo::KEY=VALUE form requires newer Cargo support, while the older cargo:KEY=VALUE form remains relevant for compatibility with older toolchains.
How Build Scripts Communicate with Cargo
Build scripts communicate with Cargo by printing special lines to standard output.
Examples:
fn main() {
println!("cargo::rerun-if-changed=build.rs");
println!("cargo::rustc-link-lib=z");
println!("cargo::rustc-link-search=native=/usr/local/lib");
}These lines are not ordinary logs. They are directives Cargo interprets.
A useful mental model is:
- stdout is part of the build-script control channel
cargo::...orcargo:...lines are instructions to Cargo
Inputs and Outputs of Build Scripts
A build script consumes several kinds of input:
- files in the package
- environment variables set by Cargo
- the host system state
- optional feature state
- dependency-provided metadata
And it produces several kinds of output:
- directives printed to Cargo
- generated files written under
OUT_DIR - metadata for dependent crates in some native-linking scenarios
A useful mental model is:
- build scripts are small build-time programs with well-defined inputs and outputs
- they should not be treated as general-purpose project setup scripts
OUT_DIR and Generated Files
OUT_DIR is the main place a build script should write generated files.
Example build script:
use std::env;
use std::fs;
use std::path::PathBuf;
fn main() {
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
let generated = out_dir.join("generated.rs");
fs::write(
&generated,
"pub fn generated_message() -> &'static str { \"hello from generated code\" }"
).unwrap();
println!("cargo::rerun-if-changed=build.rs");
}Then include it from crate code:
include!(concat!(env!("OUT_DIR"), "/generated.rs"));
pub fn message() -> &'static str {
generated_message()
}A key rule is that build scripts should write their generated outputs inside OUT_DIR, not arbitrarily into src/ or elsewhere in the repository.
Code Generation Pattern
One of the most common official build-script patterns is code generation.
Example package layout:
codegen_demo/
āāā Cargo.toml
āāā build.rs
āāā src/
āāā lib.rsbuild.rs:
use std::env;
use std::fs;
use std::path::PathBuf;
fn main() {
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
let path = out_dir.join("config_values.rs");
fs::write(
path,
"pub const BUILD_MESSAGE: &str = \"generated at build time\";"
).unwrap();
println!("cargo::rerun-if-changed=build.rs");
}src/lib.rs:
include!(concat!(env!("OUT_DIR"), "/config_values.rs"));
pub fn build_message() -> &'static str {
BUILD_MESSAGE
}Rerun Triggers
By default, Cargo conservatively decides when to re-run a build script, but build scripts can and should narrow that behavior using rerun directives.
The two most important directives are:
cargo::rerun-if-changed=PATHcargo::rerun-if-env-changed=VAR
Example:
fn main() {
println!("cargo::rerun-if-changed=build.rs");
println!("cargo::rerun-if-changed=schema/input.txt");
println!("cargo::rerun-if-env-changed=MY_NATIVE_LIB_DIR");
}This helps keep builds fast and predictable by rerunning only when relevant inputs actually changed.
Why Rerun Directives Matter
Rerun directives are one of the most important parts of build-script hygiene.
Without them, builds can become slower or more surprising because the script reruns more often than necessary. With them, Cargo has a clearer model of what invalidates the build-script result.
A practical mental model is:
- every build script should declare what it depends on
- Cargo should not have to guess more than necessary
A Common Rerun Mistake
A known pitfall is printing rerun-if-changed for a file that does not exist and is never generated, which can cause the build script to keep rerunning unnecessarily.
A healthier pattern is to only declare rerun paths that are real inputs to the build script or are guaranteed to exist when they matter.
Emitted Directives Overview
Cargo recognizes many build-script directives. Some of the most important families are:
- change tracking directives like
rerun-if-changed - linking directives like
rustc-link-libandrustc-link-search - compiler configuration directives like
rustc-cfg,rustc-check-cfg, andrustc-env - diagnostic directives like
warninganderror - metadata directives like
metadata
A useful mental model is that build-script directives are how build.rs alters the build environment and compiler behavior.
Linking Native Libraries
One of the canonical build-script uses is linking to native libraries.
Example:
fn main() {
println!("cargo::rustc-link-lib=z");
println!("cargo::rustc-link-search=native=/usr/local/lib");
}This tells Cargo to pass native-linking information to the compiler.
A minimal manifest for a crate that links a native library may also include links.
Example:
[package]
name = "libz_wrapper"
version = "0.1.0"
edition = "2024"
links = "z"Compiling Bundled C Code with cc
A very common official pattern is using the cc crate from [build-dependencies] to compile bundled C source.
Manifest:
[package]
name = "native_cc_demo"
version = "0.1.0"
edition = "2024"
[build-dependencies]
cc = "1"Build script:
fn main() {
cc::Build::new()
.file("src/native/foo.c")
.compile("foo");
println!("cargo::rerun-if-changed=src/native/foo.c");
}This is one of the standard ways to integrate native C code into a Cargo build.
Probing the System
Another standard build-script task is probing the system for installed libraries, headers, or platform-specific details.
A simplified pattern looks like this:
fn main() {
if let Ok(path) = std::env::var("MY_NATIVE_LIB_DIR") {
println!("cargo::rustc-link-search=native={path}");
println!("cargo::rustc-link-lib=mylib");
println!("cargo::rerun-if-env-changed=MY_NATIVE_LIB_DIR");
}
}This is often paired with crates like pkg-config, cmake, or bindgen in build dependencies, depending on the problem.
Using pkg-config
The official build-script examples discuss system-library probing patterns such as pkg-config.
A typical manifest might include:
[build-dependencies]
pkg-config = "0.3"And a build script might look like this:
fn main() {
pkg_config::probe_library("zlib").unwrap();
}This is useful when the crate depends on an already-installed system library and wants Cargo to learn the correct include and link paths.
Using bindgen or Other Generators
Build scripts are also commonly used for generating Rust bindings or derived build-time assets.
For example, a manifest might include:
[build-dependencies]
bindgen = "0.70"And the build script can generate bindings into OUT_DIR, which the crate then includes.
The key architectural point is that generated code should be treated as a build artifact, not hand-maintained source.
rustc-cfg and rustc-check-cfg
Build scripts can define custom conditional compilation knobs using rustc-cfg.
Example:
fn main() {
println!("cargo::rustc-cfg=has_native_support");
println!("cargo::rustc-check-cfg=cfg(has_native_support)");
}Then in Rust code:
#[cfg(has_native_support)]
pub fn native_support_enabled() -> bool {
true
}
#[cfg(not(has_native_support))]
pub fn native_support_enabled() -> bool {
false
}This is useful when the build script discovers something about the environment and wants the crate code to react to it.
rustc-env
Build scripts can also inject environment variables for the compiler to embed into the crate using rustc-env.
Example:
fn main() {
println!("cargo::rustc-env=BUILD_MODE=generated");
}Then in crate code:
pub fn build_mode() -> &'static str {
env!("BUILD_MODE")
}This is useful for small bits of build-time metadata that the crate should be able to read at compile time.
Diagnostics from Build Scripts
Build scripts can emit warnings and errors for human-facing diagnostics.
Example:
fn main() {
println!("cargo::warning=using fallback build path");
}And for a hard error:
fn main() {
println!("cargo::error=required native dependency was not found");
}These are useful when build-script logic detects something that the developer should know immediately.
The links Key
The links field in the package manifest is used by crates that link a native library.
Example:
[package]
name = "git2_sys_wrapper"
version = "0.1.0"
edition = "2024"
links = "git2"This helps Cargo understand native-linkage relationships and metadata flow for crates in the native-build ecosystem.
Avoiding Nondeterminism
Build scripts should avoid unnecessary nondeterminism.
Examples of risky patterns include:
- writing generated files into source directories
- depending on ambient host state without declaring rerun triggers
- using current timestamps in generated code without strong reason
- probing the environment in ways that differ silently across machines
A healthier pattern is:
- write only to
OUT_DIR - declare
rerun-if-changedandrerun-if-env-changedprecisely - keep generated content stable when inputs are stable
- make environmental assumptions explicit
Why Determinism Matters
Nondeterministic build scripts can make builds slower, harder to cache, harder to debug, and less reproducible across developers or CI.
A practical mental model is:
- build scripts should behave like disciplined functions of declared inputs
- hidden inputs create hidden rebuilds and hidden portability problems
Debugging Build Scripts
Debugging build scripts often means making their inputs and outputs more visible.
Useful techniques include:
- printing
cargo::warning=...messages - temporarily logging environment variables like
TARGET,HOST, andOUT_DIR - narrowing rerun triggers so rebuilds become more understandable
- using verbose Cargo output
For example:
cargo build -vAnd a diagnostic build script:
fn main() {
println!("cargo::warning=TARGET={}", std::env::var("TARGET").unwrap());
println!("cargo::warning=OUT_DIR={}", std::env::var("OUT_DIR").unwrap());
}A Full Native Integration Example
Suppose you have this layout:
native_demo/
āāā Cargo.toml
āāā build.rs
āāā src/
ā āāā lib.rs
āāā src/native/
āāā foo.cCargo.toml:
[package]
name = "native_demo"
version = "0.1.0"
edition = "2024"
[build-dependencies]
cc = "1"build.rs:
fn main() {
cc::Build::new()
.file("src/native/foo.c")
.compile("foo");
println!("cargo::rerun-if-changed=src/native/foo.c");
println!("cargo::rerun-if-changed=build.rs");
}src/lib.rs:
unsafe extern "C" {
fn foo_add(a: i32, b: i32) -> i32;
}
pub fn add(a: i32, b: i32) -> i32 {
unsafe { foo_add(a, b) }
}A Full Code Generation Example
Suppose you want to generate a Rust source file at build time.
Cargo.toml:
[package]
name = "codegen_demo"
version = "0.1.0"
edition = "2024"build.rs:
use std::env;
use std::fs;
use std::path::PathBuf;
fn main() {
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
let generated = out_dir.join("generated.rs");
fs::write(
&generated,
"pub fn generated_answer() -> u32 { 42 }"
).unwrap();
println!("cargo::rerun-if-changed=build.rs");
}src/lib.rs:
include!(concat!(env!("OUT_DIR"), "/generated.rs"));
pub fn answer() -> u32 {
generated_answer()
}Common Beginner Mistakes
Mistake 1: using build.rs for tasks that belong in normal Rust code.
Mistake 2: writing generated files into src/ instead of OUT_DIR.
Mistake 3: forgetting rerun-if-changed or rerun-if-env-changed, leading to unnecessary reruns.
Mistake 4: treating stdout as ordinary logging and not realizing that cargo::... lines are part of Cargo's control interface.
Mistake 5: depending on ambient machine state without making it explicit.
Mistake 6: making build scripts nondeterministic through timestamps, random data, or hidden host assumptions.
Hands-On Exercise
Create a small crate with a build script that generates code.
Start here:
cargo new build_script_lab --lib
cd build_script_labCreate build.rs:
use std::env;
use std::fs;
use std::path::PathBuf;
fn main() {
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
let generated = out_dir.join("generated.rs");
fs::write(
&generated,
"pub fn generated_message() -> &'static str { \"hello from build.rs\" }"
).unwrap();
println!("cargo::rerun-if-changed=build.rs");
}Replace src/lib.rs with:
include!(concat!(env!("OUT_DIR"), "/generated.rs"));
pub fn message() -> &'static str {
generated_message()
}Then run:
cargo build
cargo test
cargo build -vThen modify build.rs slightly and rebuild to observe rerun behavior. This exercise makes build-script inputs, outputs, and rerun triggers concrete.
Mental Model Summary
A strong mental model for Cargo build scripts is:
build.rsis a build-time Rust program that Cargo runs before compiling the crate- it exists for code generation, native integration, probing, and build-time configuration that normal crate code cannot express alone
- build scripts communicate with Cargo through emitted
cargo::...directives on stdout - generated files belong in
OUT_DIR - precise rerun triggers are essential for correctness and performance
- build dependencies belong in
[build-dependencies] - good build scripts are deterministic, explicit about inputs, and easy to debug
Once this model is stable, build scripts become much easier to use as disciplined build-time tools instead of mysterious project glue.
