How to Read Environment Variables in Rust

Read environment variables in Rust using std::env::var to retrieve values or handle missing keys.

The contract with the outside world

You are deploying a service. The configuration lives in the deployment platform, not in your binary. Your code needs to read DATABASE_URL. If that variable is missing, the service cannot start. If the variable contains a typo, the service should crash immediately rather than connecting to the wrong database. Rust forces you to make these decisions explicit. The standard library provides std::env to interact with the process environment, and it treats every value as potentially missing or malformed.

Environment variables are key-value pairs passed to your process by the operating system. They are the standard way to configure applications without recompiling. Rust models them as strings, but with a twist. The std::env::var function returns a Result<String, VarError>. This return type encodes two failure modes. The variable might not exist. Or the variable might exist but contain bytes that are not valid UTF-8. Rust refuses to give you a String until you acknowledge these risks.

Reading a value

The entry point is std::env::var. You pass the name of the variable. The function returns a Result. The Ok variant holds the value as a String. The Err variant holds a VarError enum. The enum has two variants: NotPresent and NotUnicode. This distinction matters. A missing variable is a configuration error. A non-UTF-8 variable is usually a platform quirk or a user mistake. Your error handling can branch based on which variant you receive.

use std::env;
use std::env::VarError;

/// Reads a configuration value and handles missing or invalid cases.
fn read_database_url() {
    // var returns Result<String, VarError>.
    // This forces you to handle the missing case explicitly.
    match env::var("DATABASE_URL") {
        Ok(url) => println!("Connecting to {}", url),
        Err(VarError::NotPresent) => {
            // The variable is missing. This is a configuration error.
            eprintln!("Error: DATABASE_URL is not set.");
        }
        Err(VarError::NotUnicode(_)) => {
            // The variable exists but contains invalid UTF-8.
            // This is rare but possible on some systems.
            eprintln!("Error: DATABASE_URL contains invalid characters.");
        }
    }
}

The match expression handles the Result. If var returns Ok, you have a valid UTF-8 string. If it returns Err, you get a VarError. You can pattern match on the variants to provide specific error messages. This is better than a generic panic. It tells the user exactly what went wrong.

Convention aside: Naming collisions are real. If you build a library, prefix your environment variables with your crate name. Use MY_CRATE_FEATURE instead of FEATURE. This prevents your library from stepping on the user's variables or other libraries' variables. The Rust community follows this pattern to keep the environment namespace clean.

Always handle the Result. Ignoring it turns a configuration error into a runtime panic or silent corruption.

Handling missing values

Not every environment variable is mandatory. Some have sensible defaults. When a default exists, you can avoid the boilerplate of match. The Result type provides combinators that simplify this pattern. unwrap_or and unwrap_or_else are the standard tools. They extract the value if present, or return a fallback if missing.

use std::env;

/// Gets the server port from env, defaulting to 8080.
fn get_port() -> u16 {
    // unwrap_or_else takes a closure that runs only if the variable is missing.
    // This lazy evaluation avoids computing the default unnecessarily.
    let port_str = env::var("PORT").unwrap_or_else(|_| "8080".to_string());

    // parse converts String to u16, returning Result.
    // We chain unwrap_or to handle parsing failures gracefully.
    port_str.parse::<u16>().unwrap_or(8080)
}

The closure in unwrap_or_else runs only if the variable is missing. This lazy evaluation matters when the default is expensive to compute. If you use unwrap_or, the default computes every time, even if the variable is present. For simple literals, unwrap_or is fine. For complex logic, unwrap_or_else saves work.

If the variable is mandatory, use expect. expect panics if the variable is missing, but it includes a custom message. This is better than unwrap, which panics with a generic message. The custom message helps users diagnose the problem.

use std::env;

/// Reads a mandatory secret key.
fn get_secret_key() -> String {
    // expect panics with a message if the variable is missing.
    // This is appropriate for mandatory configuration.
    env::var("SECRET_KEY").expect("SECRET_KEY must be set for this application to run")
}

Prefer unwrap_or_else for defaults. It saves work and signals intent. Use expect for mandatory values. It fails fast with a clear message.

The OsString trap

File paths are the most common place where var fails. On Windows, paths can contain characters outside the UTF-8 range. If a user sets MY_PATH to a directory with a special character, env::var returns Err(VarError::NotUnicode). Your app crashes. The fix is env::var_os. This function returns an OsString. An OsString holds raw OS bytes. It does not guarantee UTF-8. It represents the native string type of the operating system.

You cannot print an OsString directly. The compiler rejects you with E0277 (the trait std::fmt::Display is not implemented for std::ffi::OsString). You must convert it to a String or a PathBuf. If you are dealing with file paths, convert to PathBuf. PathBuf understands OS strings and handles the conversion safely.

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

/// Reads a file path from the environment.
fn get_config_path() -> PathBuf {
    // var_os returns Result<OsString, VarError>.
    // This handles non-UTF-8 paths correctly on Windows.
    let path_os = env::var_os("CONFIG_PATH")
        .expect("CONFIG_PATH must be set");

    // PathBuf::from accepts OsString directly.
    // This avoids UTF-8 conversion errors for paths.
    PathBuf::from(path_os)
}

PathBuf::from accepts OsString directly. This avoids UTF-8 conversion errors for paths. If you need to read the path as a string later, you can call to_str on the PathBuf. to_str returns Option<&str>. It returns None if the path contains non-UTF-8 bytes. This gives you control over how to handle the failure.

Use var_os for paths. Convert to PathBuf immediately. Never try to force a path into a String.

Pitfalls and compiler errors

Beginners often confuse environment variables with command-line arguments. Environment variables are set in the shell before the process starts. Command-line arguments are passed on the command line. Use std::env::args to read arguments. args returns an iterator of String values. The first element is the program name. Environment variables do not include the program name. If you need to iterate over all environment variables, use std::env::vars. It returns an iterator of (String, String) pairs.

Another pitfall is mutating environment variables. std::env::set_var allows you to change variables. This affects the current process and any child processes you spawn. It does not affect the parent shell. Use set_var sparingly. Global mutable state creates subtle bugs. If you need to pass configuration to a child process, use the Command API to set variables explicitly for that process.

If you try to assign the result of var to a String variable without handling the Result, the compiler rejects you with E0308 (mismatched types: expected String, found Result<String, VarError>). This error forces you to handle the missing case. It prevents silent failures.

Treat environment variables as read-only configuration. Writing to them is a coordination hazard.

Decision matrix

Use env::var when you need a UTF-8 string and want to handle missing or invalid cases explicitly. Use env::var_os when you need raw OS bytes, such as file paths that might contain non-UTF-8 characters. Use env::args when you need command-line arguments passed to the binary. Use env::vars when you need to iterate over all environment variables for debugging or dumping configuration. Use unwrap_or_else when a sensible default exists and the default computation is non-trivial. Use expect when the variable is mandatory and the application cannot function without it.

Pick the tool that matches the data type and the failure mode. The compiler will guide you if you pick wrong.

Where to go next