---
title: "20.4 Environment variables"
description: "Reading configuration from the environment with env::var, the Result it returns, and when an env var beats a command-line flag."
url: "https://learnrust.net/chapter-20/environment-variables/"
last_updated: "2026-06-13"
---

# 20.4 Environment variables

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:

```rust
use std::env;

fn main() {
    match env::var("HOME") {
        Ok(path) => println!("home is {path}"),
        Err(_) => println!("HOME is not set"),
    }
}
```

```bash
$ 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.

```rust
use std::env;

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

```bash
$ 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:

```rust
use std::env;

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

```bash
$ 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`?

<details class="solution">
<summary>Show solution</summary>

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.

</details>

**Question #2**

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

<details class="solution">
<summary>Show solution</summary>

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

</details>

**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?

<details class="solution">
<summary>Show solution</summary>

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.

</details>

You now have every input a command-line program can take: arguments, files, standard input, and the environment. The next two lessons ([20.5](https://learnrust.net/chapter-20/text-search-tool-part-1/) and [20.6](https://learnrust.net/chapter-20/text-search-tool-part-2/)) 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.

## Sitemap

See the full [sitemap](https://learnrust.net/sitemap.md) for all pages.
