How to read environment variables

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

When configuration crashes at startup

Your Rust service starts. It tries to read the database URL. It crashes. The panic message points to env::var("DATABASE_URL").unwrap(). The variable isn't set. This is the standard failure mode for configuration. Environment variables are the bridge between your code and the deployment environment. Rust treats them as untrusted input. The standard library forces you to handle missing values, type mismatches, and encoding issues explicitly.

You cannot assume an environment variable exists. You cannot assume it contains valid text. You cannot assume it represents the type you need. Rust's std::env module reflects this reality. Every read operation returns a Result. You must decide how to handle the error before you can use the value.

The contract of std::env

The operating system maintains a table of key-value pairs for every process. When you spawn a Rust program, that table is inherited. std::env gives you a window into that table. The core function is var. It returns a Result<String, VarError>.

The Result encodes two distinct failure modes. The variable might be missing entirely. Or the variable might exist but contain bytes that are not valid UTF-8. Rust separates these cases in the VarError enum. This distinction matters. A missing variable is a configuration error. A non-UTF-8 variable is often a platform quirk or a misuse of the variable for binary data.

use std::env;

/// Reads a UTF-8 environment variable and handles both missing and encoding errors.
fn read_mode() -> Result<String, env::VarError> {
    // env::var returns a Result. The compiler forces you to handle the error.
    // You cannot ignore the possibility that the variable is absent.
    env::var("APP_MODE")
}

fn main() {
    match read_mode() {
        Ok(mode) => println!("Running in mode: {}", mode),
        Err(env::VarError::NotPresent) => {
            // Missing variable. Provide a default or exit with context.
            println!("APP_MODE not set. Defaulting to production.");
        }
        Err(env::VarError::NotUnicode(_)) => {
            // Variable exists but contains invalid UTF-8 bytes.
            // This is rare on Unix but can happen on Windows.
            eprintln!("APP_MODE contains invalid characters.");
        }
    }
}

Trust the Result. If you unwrap without checking, you are just delaying the crash.

Under the hood: allocation and validation

When you call env::var, the runtime performs a lookup in the process environment table. If the key is found, the runtime copies the bytes into a new String allocation. It then validates that every byte sequence forms valid UTF-8 characters. If validation fails, the allocation is dropped and an error is returned.

This validation has a cost. It requires scanning the entire value. It also means var cannot handle environment variables that contain arbitrary bytes. On Windows, environment variables can store non-UTF-8 data. Paths with legacy encoding or opaque tokens might fail var.

The standard library provides var_os for this case. It returns an OsString instead of a String. OsString is an opaque container for platform-specific string data. On Unix, it is a vector of bytes. On Windows, it is a vector of UTF-16 code units. var_os skips UTF-8 validation. It is faster and safer for values that might not be text.

use std::env;

/// Reads an environment variable as OsString to handle non-UTF-8 data.
fn read_path() -> Option<env::OsString> {
    // var_os returns Option<OsString>. It never errors on encoding.
    // This is the correct choice for file paths or opaque tokens.
    env::var_os("CUSTOM_PATH")
}

fn main() {
    if let Some(path) = read_path() {
        // OsString does not implement Display. Use Debug or convert carefully.
        println!("Path found: {:?}", path);
    } else {
        println!("CUSTOM_PATH is not set.");
    }
}

Treat OsString as opaque. Convert only at the boundary where you need text.

Convention: var_os in libraries

The Rust community follows a clear convention here. Libraries should use var_os whenever they read environment variables. A library does not control the user's environment. It cannot assume UTF-8 encoding. Using var in a library risks panicking or returning errors for valid platform-specific values.

Binaries can use var safely. The binary author controls the configuration schema. You can document that values must be UTF-8. You can provide clear error messages when validation fails. The convention exists to protect library users from encoding surprises.

Realistic configuration: parsing and defaults

Real applications need typed configuration. Environment variables are always strings. You must parse them into integers, booleans, or enums. Parsing introduces another layer of failure. The string might be present but contain garbage.

The idiomatic pattern chains the environment read with the parse operation. You use unwrap_or_else for defaults. This closure-based default ensures the default value is only computed if the variable is missing. It avoids unnecessary work when the variable is present.

use std::env;
use std::num::ParseIntError;

/// Application configuration loaded from environment variables.
struct Config {
    port: u16,
    debug: bool,
    log_level: String,
}

/// Loads configuration, applying defaults and parsing types.
fn load_config() -> Result<Config, Box<dyn std::error::Error>> {
    // Read port. Default to 8080 if missing.
    // unwrap_or_else takes a closure, so the default string is only created if needed.
    let port_str = env::var("PORT").unwrap_or_else(|_| "8080".to_string());
    
    // Parse the string to u16. The ? operator propagates parse errors.
    let port: u16 = port_str.parse()?;

    // Read debug flag. Check if variable exists and equals "true".
    // This pattern handles missing vars gracefully without panicking.
    let debug = env::var("DEBUG").ok().map(|v| v == "true").unwrap_or(false);

    // Read log level. Default to "info".
    let log_level = env::var("LOG_LEVEL").unwrap_or_else(|_| "info".to_string());

    Ok(Config { port, debug, log_level })
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let config = load_config()?;
    println!("Port: {}, Debug: {}, Log: {}", config.port, config.debug, config.log_level);
    Ok(())
}

Never assume an environment variable is safe. Validate it, parse it, and default it.

Pitfalls: testing, security, and global state

Environment variables introduce global mutable state. This creates problems in testing and security.

Tests in Rust run in parallel by default. If one test calls env::set_var("FOO", "1"), it modifies the global environment. Another test running on a different thread might see FOO=1 when it expects FOO to be unset. This causes flaky tests. The race condition is invisible until it fails intermittently.

The solution is isolation. Use the temp_env crate for tests. It sets a variable, runs a closure, and restores the previous value. It uses thread-local storage to avoid interfering with other tests.

// In tests, use temp_env to isolate environment changes.
// This prevents race conditions with parallel test execution.
use temp_env;

#[test]
fn test_debug_mode() {
    temp_env::with_var("DEBUG", Some("true"), || {
        // Inside this closure, DEBUG is set to "true".
        // Other tests are unaffected.
        assert!(env::var("DEBUG").is_ok());
    });
    
    // After the closure, DEBUG is restored to its previous state.
    assert!(env::var("DEBUG").is_err());
}

Security is another concern. Environment variables are visible to other processes running under the same user account. On Linux, you can read /proc/PID/environ. On macOS, ps aux shows environment variables. They are not encrypted. They are not restricted by file permissions.

Never store database passwords or API keys in environment variables on shared systems. Use a secrets manager, a file with restricted permissions, or a hardware security module. Environment variables are convenient, not secure.

Unicode handling requires care. If you use var_os and need to display the value, you cannot call to_string() directly. OsString does not implement ToString. You must use to_str() which returns Option<&str>, or to_string_lossy() which returns Cow<str>.

to_string_lossy is a performance optimization. It returns a borrowed &str if the data is valid UTF-8. No allocation occurs. If the data contains invalid bytes, it allocates a String and replaces bad bytes with the Unicode replacement character. This handles the common case efficiently while providing a fallback for the rare case.

use std::borrow::Cow;

fn safe_display(path: &env::OsStr) -> Cow<str> {
    // to_string_lossy returns Cow<str>.
    // It borrows if valid UTF-8, allocates only if replacement is needed.
    path.to_string_lossy()
}

fn main() {
    if let Some(path) = env::var_os("PATH") {
        let display = safe_display(&path);
        println!("Path: {}", display);
    }
}

Global state kills parallel tests. Isolate your environment changes.

Decision matrix

Use env::var when you need a UTF-8 string and want a clear error if the variable is missing or malformed. Use env::var_os when performance matters, the value might be non-UTF-8, or you are writing a library that must handle arbitrary platform data. Use env::vars when you need to iterate over all environment variables, such as for debugging or exporting configuration. Use std::env::args when you need command-line arguments, not environment variables. Use the dotenv crate when you are developing locally and want to load variables from a .env file without modifying the system environment. Use the config crate when your application mixes environment variables, JSON files, and defaults in a complex hierarchy.

Where to go next