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.rs prepares 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.rs

The 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 = false

When 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 results

This 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 for build.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::... or cargo:... 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.rs

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 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=PATH
  • cargo::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-lib and rustc-link-search
  • compiler configuration directives like rustc-cfg, rustc-check-cfg, and rustc-env
  • diagnostic directives like warning and error
  • 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 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-changed and rerun-if-env-changed precisely
  • 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, and OUT_DIR
  • narrowing rerun triggers so rebuilds become more understandable
  • using verbose Cargo output

For example:

cargo build -v

And 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.c

Cargo.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_lab

Create 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 -v

Then 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.rs is 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.