env.dev

Environment Variables in Rust: std::env, dotenvy & envy

Read env vars in Rust with std::env::var, load .env files via dotenvy, and deserialize typed config with envy. Plus the Rust 2024 unsafe-set_var change.

Last updated:

Rust reads environment variables through std::env. The headline function is env::var, which returns a Result<String, VarError> distinguishing "not set" from "not valid Unicode". For non-UTF-8 paths, env::var_os returns an OsString. Mutating the environment is uniformly unsafe in Rust 2024 edition (June 2024) — env::set_var and env::remove_var require an unsafe block — because the C library functions they wrap are not thread-safe on most platforms. For loading .env files, the dotenvy crate is the maintained successor to the original dotenv; for typed configuration, envy deserializes via serde. This guide covers the lot, plus the env! macro that bites first-time Rust authors.

How do you read an environment variable in Rust?

env::var takes a key and returns a Result<String, VarError>. The error has two variants: NotPresent for "the variable is not set" and NotUnicode(OsString) for "the value contains bytes that aren't valid UTF-8". Use env::var_os if you need to handle non-Unicode values without losing data — it returns Option<OsString>.

rust
use std::env;

fn main() {
    // The match-on-Result pattern
    match env::var("DATABASE_URL") {
        Ok(url) => println!("Database: {url}"),
        Err(env::VarError::NotPresent) => eprintln!("DATABASE_URL not set"),
        Err(env::VarError::NotUnicode(raw)) => {
            eprintln!("DATABASE_URL is not valid UTF-8: {raw:?}")
        }
    }

    // Default value with combinators
    let port: u16 = env::var("PORT")
        .unwrap_or_else(|_| "3000".to_string())
        .parse()
        .expect("PORT must be a number");

    // Iterate every variable
    for (key, value) in env::vars() {
        println!("{key}={value}");
    }
}

Why is set_var unsafe now?

On POSIX, setenv mutates a global variable that other threads may be reading concurrently — a classic data race that the libc spec does not protect against. The Rust 2024 edition promoted env::set_var and env::remove_var from "safe but discouraged" to genuinely unsafe. The fix is structural: read every variable you care about early in main, before spawning threads or starting an async runtime, and store the values in a configuration struct.

rust
// Rust 2024 edition — safe pattern
struct Config {
    database_url: String,
    port: u16,
}

fn main() -> anyhow::Result<()> {
    // Reading is safe
    let config = Config {
        database_url: env::var("DATABASE_URL")?,
        port: env::var("PORT").unwrap_or_default().parse().unwrap_or(3000),
    };

    start_server(config);  // pass config in, don't reach for env again
    Ok(())
}

// Mutating still works, but now requires unsafe
unsafe { env::set_var("MY_VAR", "value"); }

How do you load a .env file with dotenvy?

dotenvy is the actively-maintained fork of the original dotenv crate (which has been unmaintained since 2020). Add it to Cargo.toml and call dotenvy::dotenv() at the top of main:

toml
# Cargo.toml
[dependencies]
dotenvy = "0.15"
rust
use std::env;

fn main() {
    // Loads .env from the current directory; ignores absence
    dotenvy::dotenv().ok();

    let db_url = env::var("DATABASE_URL")
        .expect("DATABASE_URL must be set");
    // ...
}

For a complete reference on .env file syntax, quoting, and multi-line values, see the .env file guide.

How do you build a typed config struct?

For anything beyond two or three variables, hand-rolled parsing turns into boilerplate fast. The envy crate deserializes environment variables into a serde-derived struct — defaults, optional fields, and nested types all work out of the box.

rust
use serde::Deserialize;

#[derive(Deserialize)]
struct Config {
    database_url: String,
    #[serde(default = "default_port")]
    port: u16,
    #[serde(default)]
    debug: bool,
}

fn default_port() -> u16 { 3000 }

fn main() -> Result<(), Box<dyn std::error::Error>> {
    dotenvy::dotenv().ok();
    let config: Config = envy::from_env()?;
    println!("Listening on :{}", config.port);
    Ok(())
}

For multi-source configuration (env vars + TOML + CLI flags with layered precedence), reach for the config crate or figment instead.

env! vs env::var — which is which?

Rust has a compile-time and a runtime path for environment variables, and the names are confusingly close. The env! macro reads at compile time: the value is baked into the binary. std::env::var reads at runtime from the process environment. Mixing them up causes "the value didn't change after I edited the .env file" bugs.

rust
// Compile-time — the value is frozen into the binary
const VERSION: &str = env!("CARGO_PKG_VERSION");
const GIT_SHA: &str = env!("GIT_SHA"); // must be set when cargo build runs

// Runtime — read from the process environment when called
let log_level = std::env::var("LOG_LEVEL").unwrap_or("info".into());

Practical patterns and gotchas

  • Read all config in main, before threads. This sidesteps the set_var / data-race problem and gives you a single place to fail fast on missing values.
  • Don't unwrap in production code. Use .expect("DATABASE_URL must be set") or proper error handling so the failure message points to the missing variable.
  • env::var_os for paths: Linux filenames can contain arbitrary bytes, and Windows uses UTF-16. env::var drops bytes that aren't valid UTF-8.
  • dotenvy, not dotenv. The original dotenv crate is unmaintained. dotenvy is API-compatible and actively developed.
  • Generate or validate your .env files with the .env builder and the .env validator before committing.

For Rust's compile-time constants story, see constants in Rust. For language-agnostic security and validation rules, see environment variable best practices.

Was this helpful?

Read next

Environment Variables in Java: System.getenv & Spring Boot

Read env vars in Java with System.getenv() (immutable from inside the JVM) and bind them to typed config via Spring Boot relaxed binding. Plus dotenv-java for non-Spring apps.

Continue →

Frequently Asked Questions

How do I read an environment variable in Rust?

Use std::env::var("KEY"), which returns Result<String, VarError>. The error distinguishes "not set" (NotPresent) from "not valid UTF-8" (NotUnicode). For non-UTF-8 paths use env::var_os, which returns Option<OsString>.

Why is env::set_var unsafe in Rust 2024?

On POSIX, setenv mutates a global the C library does not protect from concurrent reads — a race the standard does not guarantee against. Rust 2024 promoted env::set_var and env::remove_var to genuinely unsafe so the call site has to opt in.

Should I use dotenv or dotenvy?

Use dotenvy. The original dotenv crate has been unmaintained since 2020. dotenvy is API-compatible, actively developed, and the de-facto standard for loading .env files in modern Rust.

env! vs env::var — which is which?

env! is a macro that reads at compile time and bakes the value into the binary. std::env::var reads from the process environment at runtime. Mixing them up causes "the value did not change after I edited the .env file" bugs.

Stay up to date

Get notified about new guides, tools, and cheatsheets.