Understanding Rust Build Scripts: Why Does `build.rs` Exist?

C…
crusty.rustacean
Published on October 31, 2025 • Updated November 10, 2025

An introduction to build scripts in Rust development

posts
rust compile-time build-script
0 likes

If you’ve poked around Rust projects, you’ve probably seen a build.rs file sitting next to Cargo.toml and wondered, “What is this for?” I know I have. I’d see it in projects, maybe cargo would mention it in build output, but I never really understood why it was there or what problem it solved.

Today I finally figured it out while working on my WordPress-like CMS project, and I want to share what I learned.

The Problem That Made Me Care

I’m building a web application called Rusty Word Smith. It’s a Cargo workspace with this structure:

rusty-word-smith/
├── Cargo.toml          # Workspace root
├── templates/          # HTML templates
├── rws-core/           # Main server crate
│   ├── Cargo.toml
│   ├── configuration/  # Config files
│   └── src/
└── rws-service/        # Business logic

My application needs to find two things at runtime:

  1. Configuration files in rws-core/configuration/
  2. Templates in templates/ (at workspace root)

Here’s where it got annoying. These commands behaved differently:

# From workspace root - works fine
cargo run

# From workspace root with package flag - crashes!
cargo run -p rws-core
# Error: configuration directory not found

# From inside rws-core - also crashes!
cd rws-core && cargo run
# Error: templates directory not found

Why? Because my code was using std::env::current_dir(), which gives you wherever the user ran the command from. That’s not what I needed - I needed to know where my project files actually live, regardless of where the user is standing.

Enter Build Scripts

Build scripts are special Rust files that run during compilation, before your actual code compiles. They can:

  • Set compile-time environment variables
  • Generate code
  • Compile C/C++ dependencies
  • Download external resources
  • Basically anything you need to prepare for compilation

The key insight: build scripts run at compile time, not runtime. This means they can capture information about your project structure and embed it into your binary.

My Solution: Capture Workspace Paths at Compile Time

Here’s what I did. First, I created rws-core/build.rs:

use std::env;
use std::path::PathBuf;

fn main() {
    // Get the directory where this Cargo.toml lives (rws-core/)
    let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
    
    // Get the workspace root (parent directory)
    let workspace_root = manifest_dir.parent().unwrap();
    
    // Tell rustc to set these as compile-time environment variables
    println!("cargo:rustc-env=WORKSPACE_ROOT={}", workspace_root.display());
    println!("cargo:rustc-env=CRATE_ROOT={}", manifest_dir.display());
}

Then I told Cargo about it in rws-core/Cargo.toml:

[package]
name = "rws-core"
version = "0.7.0"
edition = "2024"
build = "build.rs"  # ← This line tells Cargo to run the build script

Now in my actual application code, I can do this:

use std::path::PathBuf;

fn find_config_dir() -> PathBuf {
    // This reads the WORKSPACE_ROOT that was set at compile time
    let workspace_root = PathBuf::from(env!("WORKSPACE_ROOT"));
    workspace_root.join("rws-core/configuration")
}

fn find_templates_dir() -> PathBuf {
    let workspace_root = PathBuf::from(env!("WORKSPACE_ROOT"));
    workspace_root.join("templates")
}

And boom - it works from anywhere!

cargo run              # ✓ Works
cargo run -p rws-core  # ✓ Works
cd rws-core && cargo run  # ✓ Works

The Magic: env!() vs std::env::var()

This confused me at first, so let me clarify:

std::env::var("VAR_NAME") - Runtime environment variable

  • Reads from your shell when the program runs
  • Changes based on where/how you run it
  • Returns Result<String, VarError>
// This reads from your shell environment when the program runs
let home = std::env::var("HOME").unwrap();

env!("VAR_NAME") - Compile-time environment variable

  • Reads from build script during compilation
  • Gets embedded into the binary as a constant
  • Returns &'static str (a constant string)
  • Fails compilation if the variable doesn’t exist
// This was set by build.rs during compilation
const WORKSPACE_ROOT: &str = env!("WORKSPACE_ROOT");

The env!() macro is evaluated at compile time and becomes a hardcoded string in your binary. It’s as if you wrote:

const WORKSPACE_ROOT: &str = "/home/jeff/Development/rusty-word-smith";

But the build script figured out the path automatically.

What Can Build Scripts Do?

Beyond setting environment variables, build scripts can:

1. Compile Native Dependencies

If your Rust project needs to link against C/C++ code:

// build.rs
fn main() {
    cc::Build::new()
        .file("src/native/helper.c")
        .compile("helper");
}

2. Code Generation

Generate Rust code based on external data:

// build.rs
fn main() {
    let sql_queries = std::fs::read_to_string("queries.sql").unwrap();
    // Parse SQL and generate Rust structs...
    let generated = generate_query_functions(&sql_queries);
    std::fs::write("src/generated.rs", generated).unwrap();
}

3. Feature Detection

Check what’s available on the system:

// build.rs
fn main() {
    if pkg_config::probe_library("openssl").is_ok() {
        println!("cargo:rustc-cfg=has_openssl");
    }
}

Then in your code:

#[cfg(has_openssl)]
fn secure_connection() { /* Use OpenSSL */ }

#[cfg(not(has_openssl))]
fn secure_connection() { /* Use rustls fallback */ }

4. Custom Build Flags

Pass information to rustc:

// build.rs
fn main() {
    println!("cargo:rustc-link-search=native=/usr/local/lib");
    println!("cargo:rustc-link-lib=static=mylib");
    println!("cargo:rerun-if-changed=build.rs");
}

Important Build Script Commands

Build scripts communicate with Cargo through println! with special prefixes:

// Set a compile-time env var (accessible with env!())
println!("cargo:rustc-env=VAR_NAME=value");

// Add a library search path
println!("cargo:rustc-link-search=native=/path/to/libs");

// Link a library
println!("cargo:rustc-link-lib=static=mylib");

// Re-run build script if file changes
println!("cargo:rerun-if-changed=path/to/file");

// Tell Cargo about environment variables it should watch
println!("cargo:rerun-if-env-changed=CC");

// Add a cfg flag
println!("cargo:rustc-cfg=feature_name");

// Show warnings during build
println!("cargo:warning=This is a warning message");

When Should You Use a Build Script?

Use a build script when you need to:

Capture compile-time information (like I did with workspace paths) ✅ Compile native dependencies (C/C++ code, system libraries) ✅ Generate code from external sources (protobuf, SQL, JSON schemas) ✅ Detect system capabilities (available libraries, CPU features) ✅ Embed version info (git commit hash, build timestamp)

Don’t use a build script when:

Runtime configuration (use config files or env vars instead) ❌ Simple tasks (if include_str!() or include_bytes!() works, use those) ❌ Things that change often (build scripts only run when dependencies change)

Common Pitfalls

1. Build Scripts Run During Compilation

Your build script output isn’t shown when you cargo run - it only runs during build. To see it:

cargo clean
cargo build -vv  # Very verbose

2. They Don’t Re-run Automatically

By default, build scripts only re-run when:

  • Dependencies change
  • The build script itself changes
  • Cargo.toml changes

Add this to make it re-run when specific files change:

println!("cargo:rerun-if-changed=config/settings.toml");

3. Build Scripts Must Be Fast

They run every time someone builds your project. Keep them quick:

  • Cache expensive operations
  • Don’t make network requests if avoidable
  • Use cargo:rerun-if-changed to avoid unnecessary re-runs

Real-World Example: Version Info

Here’s a practical example I see in many projects - embedding git info:

// build.rs
use std::process::Command;

fn main() {
    // Get git commit hash
    let output = Command::new("git")
        .args(&["rev-parse", "HEAD"])
        .output()
        .unwrap();
    let git_hash = String::from_utf8(output.stdout).unwrap();
    
    println!("cargo:rustc-env=GIT_HASH={}", git_hash.trim());
    
    // Get build timestamp
    let timestamp = chrono::Utc::now().to_rfc3339();
    println!("cargo:rustc-env=BUILD_TIME={}", timestamp);
}

Then in your code:

pub fn version_info() -> String {
    format!(
        "Version {} ({})\nBuilt: {}",
        env!("CARGO_PKG_VERSION"),
        &env!("GIT_HASH")[..8],
        env!("BUILD_TIME")
    )
}

Debugging Build Scripts

Build scripts are just Rust programs, so you can debug them:

# See verbose build script output
cargo build -vv

# Set env var to see all cargo messages
CARGO_LOG=cargo::core::compiler::fingerprint=info cargo build

# Add debug prints to your build.rs
eprintln!("DEBUG: workspace_root = {}", workspace_root.display());

Remember: println! goes to cargo, eprintln! goes to stderr (visible to you).

Conclusion

Build scripts are Rust’s way of letting you run code at compile time to prepare for building your project. They’re perfect for capturing information about your build environment, generating code, or compiling native dependencies.

The key mental model: build scripts run once during compilation to set up your build, not during runtime. They let you embed compile-time information into your binary.

For my use case - making a workspace project runnable from any directory - build scripts were the perfect solution. They captured where my project files live at compile time, so my runtime code always knows where to find them.

Next time you see a build.rs file, you’ll know exactly what it’s doing and why it’s there.

Further Reading


What’s your experience with build scripts? Have you found clever uses for them? Let me know in the comments or reach out on social media!

C…
crusty.rustacean

Comments

Loading comments...