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>.
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 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:
# Cargo.toml
[dependencies]
dotenvy = "0.15"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.
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.
// 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::vardrops bytes that aren't valid UTF-8. - dotenvy, not dotenv. The original
dotenvcrate is unmaintained.dotenvyis 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.