How to Use Compile-Time Environment Variables in Rust (env! macro)

Use the env! macro to embed environment variable values as string literals at compile time in Rust.

The compile-time stamp

You're shipping a CLI tool. You hardcode the version string in main.rs. You release version 2.0. You forget to update the string. Users complain the help text says 1.9. You realize you need the version to come from Cargo.toml automatically, baked into the binary at build time. Or maybe you're building a web server and need a database URL that changes per deployment, but you don't want to read it from a file at runtime. You want it locked in when the code compiles.

Rust gives you env! for this. It reads an environment variable during compilation and replaces the macro call with a string literal. If the variable is missing, the build fails. The value becomes part of the binary. The binary doesn't look up the variable at runtime. It just has the string.

How it works

env! is a built-in macro. It runs during macro expansion, before the compiler generates machine code. When you write env!("VAR"), the compiler pauses. It checks the environment variables available to the rustc process. If the variable exists, the macro expands to a string literal containing the value. If the variable is missing, the compiler emits an error and stops.

Think of env! like a stamp on a factory assembly line. The worker (the compiler) checks a clipboard (the environment) for a value. If the value is there, the worker stamps it onto the part (your code). If the clipboard is blank, the worker stops the whole line. The result isn't a variable you look up later. It's a literal value burned into the binary. The binary carries the truth. The environment is just the mold.

fn main() {
    // env! expands to a &'static str literal at compile time.
    // The compiler checks for the variable right now.
    // If the variable is missing, compilation fails with an error.
    let version = env!("CARGO_PKG_VERSION");
    
    // version is a string slice pointing to data inside the binary.
    println!("Version: {}", version);
}

The free variables from Cargo

You don't need to export variables in your shell to use env! for basic metadata. Cargo automatically sets a set of environment variables before invoking the compiler. These variables start with CARGO_PKG_. They come from your Cargo.toml.

Common variables include CARGO_PKG_NAME, CARGO_PKG_VERSION, CARGO_PKG_AUTHORS, and CARGO_PKG_DESCRIPTION. You can also access TARGET for the compilation target triple, OPT_LEVEL for the optimization level, and DEBUG for whether debug info is enabled.

Convention: Use the explicit env! form for these variables. It makes the dependency on the environment clear to readers. Some developers reach for constants defined in a separate module, but for simple metadata, env! at the point of use is standard. The compiler guarantees the value is correct. If Cargo.toml changes, the next build picks it up automatically.

/// Application metadata baked in at compile time.
/// All fields are &'static str, so this struct can be stored globally.
struct AppInfo {
    name: &'static str,
    version: &'static str,
    build_target: &'static str,
}

fn get_info() -> AppInfo {
    // env! expands to &'static str literals.
    // These references are safe to return because they point to binary data.
    AppInfo {
        name: env!("CARGO_PKG_NAME"),
        version: env!("CARGO_PKG_VERSION"),
        // TARGET is set by Cargo to the target triple like x86_64-unknown-linux-gnu.
        build_target: env!("TARGET"),
    }
}

Memory and lifetimes

The return type of env! is &'static str. This lifetime matters. The 'static lifetime means the reference is valid for the entire duration of the program. When env! expands to a string literal, that literal is stored in the binary's read-only data section. The operating system loads this section into memory when the program starts. The string never moves. It never gets deallocated. The reference points to a fixed address in the program's memory image.

This is why env! is preferred for constants. You can return the reference from any function. You can store it in a global variable. You can pass it to a thread. The compiler knows the reference will never dangle because the data lives as long as the process exists. There is no heap allocation. There is no drop glue. The cost is zero at runtime.

If you need a String, you can convert it with .to_string(), but that allocates memory on the heap. Usually, you don't need to. Keep the &'static str. It's faster and safer.

Optional values with option_env!

Sometimes a configuration value is optional. You want to check if a feature flag is set, but you don't want the build to fail if it's missing. For that, use option_env!. It works like env!, but it returns Option<&'static str>. If the variable is missing, it returns None. If it's present, it returns Some(value).

fn main() {
    // option_env! returns Option<&'static str>.
    // It doesn't fail if the variable is missing.
    let debug_mode = option_env!("DEBUG_MODE");
    
    // Handle the optional value at runtime.
    match debug_mode {
        Some(val) if val == "1" => println!("Debug enabled"),
        _ => println!("Debug disabled"),
    }
}

Use option_env! when the absence of a variable is a valid state. Use env! when the absence is a configuration error that should stop the build.

Connecting to build scripts

The real power of env! appears when you combine it with build scripts. A build script is a build.rs file in your crate root. Cargo compiles and runs this script before compiling your main code. The script can output directives to Cargo. One directive is cargo:rustc-env=KEY=VALUE. This sets an environment variable for the compiler. Your code can then read it with env!.

This pattern lets you generate values dynamically at build time. You can read a file, run a command, or compute a hash, and inject the result into your code.

// build.rs
fn main() {
    // Read a variable from the shell environment.
    // This runs before your main code compiles.
    let commit_hash = std::env::var("GIT_HASH").unwrap_or_else(|_| "unknown".to_string());
    
    // Inject the value into the compiler's environment.
    // The syntax is cargo:rustc-env=KEY=VALUE.
    println!("cargo:rustc-env=GIT_HASH={}", commit_hash);
    
    // Tell Cargo to re-run this script if the shell variable changes.
    // Without this, Cargo might cache the build and ignore new values.
    println!("cargo:rerun-if-env-changed=GIT_HASH");
}
// main.rs
fn main() {
    // env! reads the variable set by build.rs.
    // If build.rs didn't set it, this would fail.
    println!("Built from commit {}", env!("GIT_HASH"));
}

Convention: Keep build scripts fast. They run on every build. Avoid heavy computation or network requests unless necessary. Use cargo:rerun-if-changed=FILE to control when the script re-runs. If you don't set rerun conditions, Cargo re-runs the script every time, which slows down builds.

Pitfalls and errors

If the variable is missing, the build fails. The compiler emits an error like error: environment variable VAR not found. There is no runtime panic. The error stops you before the binary is created. This is a feature. It prevents silent failures where a missing config causes a crash in production.

A common mistake is confusing env! with std::env::var. The std::env::var function reads environment variables at runtime. It returns a Result<String, VarError>. If you change the environment after building, std::env::var sees the change. env! does not. The value is baked in. If you need a value that changes between runs, env! is the wrong tool. Reach for std::env::var.

Another pitfall is secrets. If you use env! for a secret key, the key ends up in the binary. Anyone with the binary can extract it. Use env! for secrets only if the binary runs in a trusted environment and you never distribute the binary to untrusted users. For secrets that must stay hidden, read them at runtime from a secure store or use runtime environment variables.

Decision matrix

Use env! when the value must exist at build time and you want the compiler to fail if it's missing. Use env! for metadata like version, author, or build target that never changes after compilation. Use option_env! when the value is optional and you want to handle the absence in code rather than failing the build. Use std::env::var when the value can change between runs, like a configuration file path or a runtime flag. Use std::env::var_os when you need to handle non-UTF8 environment variables safely. Use build.rs with env! when you need to compute a value dynamically during the build process, like reading a file or running a command.

Compile-time constants catch errors early and eliminate runtime overhead. Bake the value in. The binary should never be surprised by its own configuration.

Where to go next