---
title: How to read environment variables
summary: Read environment variables in Rust using std::env::var to retrieve values or handle missing keys.
lang: en
slug: how-to-read-environment-variables
published: 2026-04-17T14:12:14+00:00
updated: 2026-04-25T22:00:13+00:00
canonical: https://www.rustfaq.org/en/how-to-read-environment-variables/
---

# How to read environment variables



## 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.

```rust
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.

```rust
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.

```rust
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.

```rust
// 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.

```rust
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

- [How to List Files in a Directory in Rust](/en/how-to-list-files-in-a-directory-in-rust/)
- [How to Use std::io for Input/Output in Rust](/en/how-to-use-stdio-for-inputoutput-in-rust/)
- [How to Append to a File in Rust](/en/how-to-append-to-a-file-in-rust/)