How to read environment variables
Short answer: std::env::var looks up one environment variable by name and returns Result<String, VarError>. It is Ok when the variable is set to valid UTF-8, Err(VarError::NotPresent) when it is not set at all, and Err(VarError::NotUnicode(..)) when it is set to bytes that are not valid UTF-8. The lookup happens at run time, every time the call runs — nothing is baked in at compile time.
From a fresh project — cargo new env-demo — replace src/main.rs with each block below and run it with cargo run. The examples read DATABASE_URL, LOG_LEVEL, THREADS and MODE, which nothing on a normal machine sets, so your output should match. HOME is the one exception: its value is your home directory, not the one printed here.
The lookup
Section titled “The lookup”use std::env;
fn main() { let home = env::var("HOME"); println!("{home:?}");}Ok("/Users/rango")That Result<String, VarError> is the whole API. There is no second function for “just give me the value”: the error case is not an edge worth hiding, because a missing variable is a normal state of a process environment, not a malfunction. If the debug spelling feels noisy once you have handled the error, the match below is the ordinary way to consume it.
The two ways it fails
Section titled “The two ways it fails”use std::env;
fn main() { match env::var("DATABASE_URL") { Ok(url) => println!("connecting to {url}"), Err(env::VarError::NotPresent) => println!("DATABASE_URL is not set"), Err(e) => println!("DATABASE_URL is set, but not readable as UTF-8: {e:?}"), }}DATABASE_URL is not setNotPresent means the name is not in this process’s environment — it was never exported, was set after the process started, or is simply spelled differently from how you are looking it up. That last one is the common bug: env::var("DATABASE_URL") and env::var("DATABASEURL") are unrelated names to the operating system, and neither case matters — variable names are case-sensitive.
NotUnicode is rarer but real. On Unix an environment value is a sequence of raw bytes, and nothing stops another program from exporting bytes that are not valid UTF-8. var promises a String, so it refuses such a value instead of inventing characters; the last section of this page shows what the error looks like and the function that hands you the raw bytes instead.
The panic that unwrap hands you
Section titled “The panic that unwrap hands you”use std::env;
fn main() { let url = env::var("DATABASE_URL").unwrap(); println!("{url}");}thread 'main' (2709259) panicked at src/main.rs:4:40:called `Result::unwrap()` on an `Err` value: NotPresentnote: run with `RUST_BACKTRACE=1` environment variable to display a backtraceunwrap is acceptable while something else guarantees the variable exists — a CI script that exports it, a container definition, a start script that fails first. But it turns “configuration is missing” into a crash with exit code 101, at the moment the line runs, with a message that never says which setting the user forgot. When absence is possible, the match above or the fallbacks below say the same thing without dying.
The fallbacks people actually use
Section titled “The fallbacks people actually use”use std::env;
fn main() { let level = env::var("LOG_LEVEL").unwrap_or_else(|_| String::from("info")); let threads: usize = env::var("THREADS").ok().and_then(|s| s.parse().ok()).unwrap_or(4); println!("{level}"); println!("{threads}");}info4unwrap_or_else gives every failure — missing or non-Unicode — the same default, and builds the String only when it is actually needed. The second line is the standard shape for numeric configuration: ok() discards the error variant, and_then parses the value, and anything unparsable falls into the same .unwrap_or(4) as an absent variable, so THREADS=banana behaves exactly like no THREADS at all. Decide deliberately whether that is what you want; if a garbage value should be an error rather than a silent default, match on the Result and report it, the way the previous section did.
The empty string counts as set
Section titled “The empty string counts as set”use std::env;
fn main() { match env::var("MODE") { Ok(mode) => println!("MODE is set: {mode:?}"), Err(env::VarError::NotPresent) => println!("MODE is not set"), Err(e) => println!("MODE unreadable: {e:?}"), }}cargo run -qMODE= cargo run -qMODE=fast cargo run -qMODE=$'\xff' cargo run -qMODE is not setMODE is set: ""MODE is set: "fast"MODE unreadable: NotUnicode("\xFF")Four runs, four different answers, and the trap is the second one: MODE= exports the variable with an empty value, and var reports it as Ok(""), not NotPresent. A variable that was exported but never given a value is present — the unwrap_or_else fallback above does not fire for it. If an empty value should mean “use the default” in your program, that is a second check you write yourself: match env::var("MODE") and treat Ok(v) if v.is_empty() alongside the missing case.
The last line is the NotUnicode arm, caught on a real run: bash’s $'\xff' puts the single byte 0xFF into the value, and the error’s debug output shows those raw bytes. Ok("fast") uses {:?} for the same reason a byte-level value is in play — quotes make an empty value visible.
When you want the raw bytes — var_os
Section titled “When you want the raw bytes — var_os”use std::env;
fn main() { match env::var_os("HOME") { Some(value) => println!("{}", value.to_string_lossy()), None => println!("HOME is not set"), }}/Users/rangovar_os returns Option<OsString> instead: None when the variable is missing, and the value otherwise, whatever bytes it holds — there is no Unicode error branch to handle. It is the right call when the value is destined for the filesystem or another system call rather than for printing or parsing, because no information is thrown away. to_string_lossy is a display convenience, not a recovery step: it substitutes U+FFFD for every byte sequence that is not UTF-8, and only the OsString itself still carries the original bytes.
Related
Section titled “Related”- Processes, environment and signals — the rest of the chapter