20.4Environment variables

Last updated June 13, 2026

There's one more way to tell a program how to behave, alongside arguments and stdin: the environment. Every process runs inside a set of named string values called environment variables, inherited from whatever launched it. PATH tells the shell where to find programs; HOME names your home directory; RUST_BACKTRACE=1 is the one you set back in chapter 12 to get a panic backtrace. A program can read these, and they're the standard way to configure behavior that's too persistent or too sensitive to type as an argument every time.

Reading a variable

std::env::var reads an environment variable by name. It returns a Result, because the variable might not be set:

use std::env;

fn main() {
    match env::var("HOME") {
        Ok(path) => println!("home is {path}"),
        Err(_) => println!("HOME is not set"),
    }
}
$ cargo run
home is /Users/ada

The Result is the honest design again: asking for a variable that isn't set isn't a crash, it's an expected outcome with an Err you handle. The error variant tells you why, the variable was absent, or (rarely) held non-UTF-8 bytes, but most code only cares about present-versus-absent.

The common pattern: a default

Most configuration variables are optional, with a sensible fallback when unset. The idiomatic shape uses the Result's unwrap_or_else (or you can match): try the variable, and if it's missing, use a default.

use std::env;

fn main() {
    let log_level = env::var("LOG_LEVEL").unwrap_or_else(|_| String::from("info"));
    println!("logging at level: {log_level}");
}
$ cargo run
logging at level: info
$ LOG_LEVEL=debug cargo run
logging at level: debug

Setting LOG_LEVEL=debug before the command puts that variable into the environment for just that run, and the program reads it. With nothing set, the unwrap_or_else supplies "info". The closure (chapter 19) runs only on the Err path, exactly when the variable is absent. This "read the env var, fall back to a default" pattern is everywhere in real tools.

Often you only care whether a variable is set at all, treating any value (or even an empty one) as "on." Then is_ok on the Result reads cleanly:

use std::env;

fn main() {
    let verbose = env::var("VERBOSE").is_ok();
    if verbose {
        eprintln!("verbose mode on");
    }
    println!("working...");
}
$ VERBOSE=1 cargo run
verbose mode on
working...

The project at the end of this chapter uses exactly this to add a case-insensitive flag, set an env var, get different behavior, without adding another command-line argument.

Env var or command-line flag?

You now have two ways to configure a tool: an argument the user types each time, or an environment variable set in the surrounding shell. They're for different jobs, and using the wrong one is a common design slip.

Reach for a command-line argument when the value is part of this specific invocation: the file to search, the query to look for, the output path. These change every run and belong right there in the command, visible in your shell history.

Reach for an environment variable when the value is configuration that persists across runs or is set once for a whole session: a default log level, an API endpoint, a "use colors" preference, a secret token. You set it once in your shell (or a startup file) and forget it; typing it as an argument on every command would be noise.

There's a security angle too. Secrets, API keys, passwords, tend to go in environment variables rather than arguments, because command-line arguments are often visible to other users via process listings and get recorded in shell history, while environment variables are more contained. It's not airtight, but it's the convention, and it's why tools read credentials from the environment.

Best practice

Use command-line arguments for the inputs that define a single run (what file, what query). Use environment variables for persistent configuration (log levels, endpoints, feature toggles) and for secrets (tokens, keys), which shouldn't sit in plain sight in arguments and shell history. A good rule of thumb: if you'd type it differently every time, it's an argument; if you'd set it once and leave it, it's an environment variable.

For advanced readers

env::var reads the environment as it was when the process started (plus any changes the process itself made). You can set a variable from inside your program with std::env::set_var, but it affects only your own process and its children, never the parent shell, a program cannot reach back and change the shell that launched it. For real tools, configuration libraries on crates.io (like those that read .env files) build on env::var; the standard-library function is the foundation they all sit on.

Quiz time

Question #1

Why does env::var return a Result rather than a plain String?

Show solution

Because the requested variable might not be set in the environment (the common case), or rarely might contain non-UTF-8 bytes. A missing variable is an expected, recoverable outcome, not a crash, so it's returned as an Err you handle, typically by supplying a default with unwrap_or_else or matching. The Result makes "the variable might be absent" impossible to forget.

Question #2

Write a line that reads a PORT environment variable and falls back to "3000" if it isn't set.

Show solution
let port = std::env::var("PORT").unwrap_or_else(|_| String::from("3000"));

env::var("PORT") returns a Result; unwrap_or_else yields the value on Ok and runs the closure (returning the default "3000") on Err, i.e. when PORT is unset. (If you then need a number, parse the result, since it's still text.)

Question #3

You're designing a tool that takes a file to process and also needs an API token. Which should be a command-line argument and which an environment variable, and why?

Show solution

The file to process should be a command-line argument: it changes every run and defines that specific invocation. The API token should be an environment variable: it's persistent configuration set once per session, and crucially it's a secret, command-line arguments are visible in process listings and shell history, while environment variables keep credentials more contained. "Different every time" → argument; "set once, and especially if secret" → environment variable.

You now have every input a command-line program can take: arguments, files, standard input, and the environment. The next two lessons (20.5 and 20.6) put them all together in a real project, a text-search tool, that also cashes in the structure, testing, and iterator lessons from earlier chapters.