Rust’s build.rs scripts are a powerful, yet often overlooked, mechanism for customizing your compilation process beyond what standard Cargo features offer. They’re essentially small Rust programs that Cargo executes before it compiles your crate, allowing you to generate code, link against system libraries, or even set environment variables that influence the build.

Let’s see build.rs in action. Imagine you have a C library, libfoo, that you want to use in your Rust project. You could use bindgen to generate Rust bindings, but what if libfoo isn’t installed system-wide or you need a specific version? You can use build.rs to compile it.

Here’s a minimal build.rs to compile a hypothetical foo.c and link against it:

// build.rs
fn main() {
    // Tell cargo to invalidate the built crate whenever the wrapper changes
    println!("cargo:rerun-if-changed=wrapper.h");

    // Compile the C code
    cc::Build::new()
        .file("foo.c")
        .compile("foo"); // Creates libfoo.a
}

And here’s a corresponding src/main.rs to use it:

// src/main.rs
extern "C" {
    fn foo_function();
}

fn main() {
    unsafe {
        foo_function();
    }
}

And the C source:

// foo.c
#include <stdio.h>

void foo_function() {
    printf("Hello from C!\n");
}

When you run cargo build, Cargo first executes build.rs. The cc::Build struct (provided by the cc crate, which you’d add to your [build-dependencies] in Cargo.toml) handles the compilation of foo.c. The .compile("foo") directive tells cc to produce a static library named libfoo.a in the target directory. Crucially, cc::Build also automatically tells Cargo where to find this library and how to link against it.

The println!("cargo:rerun-if-changed=wrapper.h"); line is a directive to Cargo. It tells Cargo to re-run build.rs if wrapper.h (or any other file specified) changes. This is essential for ensuring your build stays up-to-date.

This simple example demonstrates the core loop: build.rs runs, it performs some build-time action (like compiling C code), and it emits cargo: directives that Cargo uses to configure the actual Rust compilation.

Let’s expand on this. Suppose libfoo also requires linking against m (the math library). You can tell cc::Build about this:

// build.rs
fn main() {
    cc::Build::new()
        .file("foo.c")
        .flag("-lm") // Link against the math library
        .compile("foo");
}

The cc crate is incredibly versatile. You can specify include directories, define preprocessor macros, and much more. If you’re generating bindings using bindgen, your build.rs would typically invoke bindgen and then print the generated Rust code to stdout. Cargo captures this output and treats it as a .rs file to be compiled alongside your other source files.

Here’s a more advanced build.rs that uses bindgen to generate Rust bindings for a C header file, wrapper.h, which in turn includes foo.h (assuming foo.h declares foo_function):

// build.rs
use std::env;
use std::path::PathBuf;

fn main() {
    // Tell cargo to invalidate the built crate whenever the wrapper changes
    println!("cargo:rerun-if-changed=wrapper.h");
    println!("cargo:rerun-if-changed=foo.c"); // Also rerun if foo.c changes

    // Compile the C code
    let lib_dir = cc::Build::new()
        .file("foo.c")
        .compile("foo"); // Creates libfoo.a

    // Get the directory where libfoo.a was placed by cc::Build
    // This is usually target/<profile>/build/<crate-name>-<hash>/out/
    let lib_dir = lib_dir.parent().unwrap();

    // Tell cargo to look for shared libraries in the specified directory
    println!("cargo:rustc-link-search=native={}", lib_dir.display());
    // Tell cargo to link the static library foo
    println!("cargo:rustc-link-lib=static=foo");

    // The bindgen::Builder is the main entry point
    // to bindgen, and lets you build up options for
    // the resulting bindings.
    let bindings = bindgen::Builder::default()
        // The input header we would like to generate bindings for.
        .header("wrapper.h")
        // Tell cargo to invalidate the built crate whenever any of the
        // included header files changed.
        .parse_callbacks(Box::new(bindgen::CargoCallbacks::new()))
        // Finish the builder and generate the bindings.
        .generate()
        // Unwrap the Result and panic on failure.
        .expect("Unable to generate bindings");

    // Write the bindings to the $OUT_DIR/bindings.rs file.
    let out_path = PathBuf::from(env::var("OUT_DIR").unwrap());
    bindings
        .write_to_file(out_path.join("bindings.rs"))
        .expect("Couldn't write bindings!");
}

And wrapper.h:

// wrapper.h
#include "foo.h"

And foo.h:

// foo.h
void foo_function();

Then, in src/main.rs, you’d include the generated bindings:

// src/main.rs
// This is the module that will contain the generated bindings
// from bindgen.
#[allow(dead_code)]
#[allow(non_upper_case_globals)]
#[allow(non_camel_case_types)]
#[allow(non_snake_case)]
mod bindings {
    include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
}

use bindings::*;

fn main() {
    unsafe {
        foo_function();
    }
}

The key takeaway here is that build.rs allows you to integrate external code (like C libraries) seamlessly. It’s not just about compiling; it’s about configuring the Rust compiler itself. You can emit cargo: directives for:

  • cargo:rustc-link-search=native=<path>: Adds a directory to the linker’s search path for libraries.
  • cargo:rustc-link-lib=<kind>=<name>: Tells the linker to link against a specific library. kind can be static, dylib, or framework.
  • cargo:rustc-env=<VAR>=<value>: Sets an environment variable that will be available to the Rust compiler (rustc) during the build. This can be used to conditionally compile code using #[cfg(env = "VAR")].
  • cargo:rerun-if-changed=<file>: Triggers a rebuild of the crate if the specified file(s) change.

The most surprising thing about build.rs is how it fundamentally alters the compilation pipeline by allowing pre-compilation steps that directly influence the linker and compiler flags. It’s not just a build script; it’s a configuration generator for your Rust build.

When you use println!("cargo:rustc-link-lib=static=foo");, you’re not just telling Cargo to link a library named foo. You’re instructing the Rust compiler (rustc) to pass specific flags to the system linker (like -lfoo for a shared library or -lfoo -static depending on the context and linker) that will resolve the symbol foo during the linking phase. This mechanism is how Rust interfaces with virtually any existing C or C++ library without needing to rewrite it.

The next step you’ll likely encounter is managing complex dependency graphs where your build.rs needs to orchestrate the compilation and linking of multiple C/C++ libraries, potentially with different build configurations.

Want structured learning?

Take the full Cargo course →