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:

DirectiveEffect
cargo:rustc-env=KEY=VALUEsets an env var readable via env!("KEY") in the crate
cargo:rustc-cfg=NAMEenables #[cfg(NAME)] blocks (custom conditional compilation)
cargo:rustc-link-lib=NAMElinks a native library
cargo:rustc-link-search=PATHadds a directory to the library search path
cargo:warning=MESSAGEprints a build warning to the terminal
cargo:rerun-if-changed=PATHrebuild-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 into src/. OUT_DIR lives under target/, so it's git-ignored, cleaned by cargo clean, and isolated per build profile. Generating into src/ instead pollutes your source tree, risks committing machine-generated files, and causes spurious diffs. The include!(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-changed line 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 a rerun-if-changed for each file (or directory) the script actually depends on, the moment you add a build.rs that reads external inputs, audit that every input is tracked.


When You Need One (and When You Don't)

NeedBuild script?
Generate Rust from a schema/IDL (.proto, grammar)yes → OUT_DIR + include!
Compile/link C code for FFIyes → cc crate + rustc-link-*
Bake in build metadata (git hash, timestamp)yes → rustc-env
Enable a cfg based on the target/environmentyes → rustc-cfg
Toggle features at compile time by flagno → Cargo features (Cargo Features)
Transform your own source based on its structureno → a proc macro
One-time project scaffoldingno → 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.rs in the crate root is a normal Rust program Cargo compiles and runs before the crate; it communicates results by printing cargo: 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 (under target/, git-ignored, profile-isolated), never into src/; pull them in with include!(concat!(env!("OUT_DIR"), "/file.rs")).
  • Cargo re-runs build.rs only when it changes unless you emit cargo: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.