How to Use .env Files in Rust (dotenvy crate)

Load environment variables from a .env file in Rust by adding the dotenvy crate and calling dotenvy::dotenv() at startup.

The missing piece in local development

You finish building a local API. It works perfectly on your machine. You push the code to a teammate, and their terminal immediately throws a panic. The database connection string is missing. You forgot that your local setup relies on a secret you typed into your shell months ago. Hardcoding credentials is a security risk. Passing them through command-line arguments is tedious. The industry standard is a plain text file named .env that lives in your project root. Rust does not load these files automatically. You need a small crate to bridge the gap between your local file and the operating system's environment.

Why Rust leaves environment variables to you

The standard library provides std::env::var to read variables that the operating system has already handed to your process. That is the boundary. Rust treats configuration as an external concern. The language designers deliberately kept file parsing out of the standard library. Different projects need different formats. Some teams use JSON. Some use YAML. Some use plain key-value pairs. Bundling a parser would force a single convention on every Rust project.

Keeping it out of the standard library also preserves a clean separation between runtime execution and deployment strategy. Your code should not care whether a secret came from a file, a CI dashboard, or a Kubernetes secret. It should only care that the value exists when the program starts. This philosophy forces you to make configuration explicit. You choose the tool. You choose when it runs. You choose how errors propagate.

How dotenvy bridges the gap

The dotenvy crate implements the exact behavior popularized by the JavaScript dotenv package. It reads a .env file, parses it line by line, and injects the key-value pairs into the OS environment. The y in the name is a nod to the original tool. The crate is lightweight, has no external dependencies, and focuses on one job. It does not validate types. It does not merge multiple files. It does not expand variables. It simply loads a file into the environment so the rest of your code can use standard library functions.

Loading a .env file with dotenvy

Add the crate to your project. Open Cargo.toml and add the dependency under the [dependencies] section.

[dependencies]
dotenvy = "0.15"

Create a .env file in the same directory as your Cargo.toml. Keep it simple for now.

DATABASE_URL=postgres://user:pass@localhost/mydb
API_KEY=secret123

Write the loading logic in main. The crate provides a single function that handles the file discovery and parsing.

use dotenvy::dotenv;
use std::env;

fn main() -> Result<(), dotenvy::Error> {
    // Load the .env file into the OS environment.
    // This must happen before you read any variables.
    dotenv()?;

    // Read the variable from the OS environment.
    // The standard library handles the lookup after dotenvy injects it.
    let db_url = env::var("DATABASE_URL")
        .expect("DATABASE_URL must be set in .env or shell");

    println!("Connecting to: {}", db_url);
    Ok(())
}

Run cargo run. The output prints your connection string. The file is gone from memory, but the values live in the process environment until the program exits.

What happens under the hood

When dotenv() executes, it searches for a file named .env starting from the current working directory and moving up the directory tree. If it finds one, it opens it and reads every line. Blank lines and lines starting with # get ignored. Every other line splits at the first = character. The left side becomes the key. The right side becomes the value. Whitespace around the key gets trimmed. Quotes around the value get stripped if they match.

The crate then calls the standard library function that sets environment variables. This overwrites any existing value for that key. If you already have DATABASE_URL set in your shell, the .env file will replace it. This behavior matches the original dotenv tool from JavaScript. It prioritizes local development convenience over strict immutability.

After the file loads, std::env::var reads directly from the OS. It does not look at the file again. The file is just a delivery mechanism. Once the values are in the environment, you interact with them exactly like any other system variable.

Treat the .env file as a local development convenience. Never assume it exists in production.

Real-world configuration setup

Production code rarely just prints values to stdout. You usually parse them into a configuration struct. This keeps your application logic clean and separates parsing from business rules.

use dotenvy::dotenv;
use std::env;

/// Holds all external configuration for the application.
struct AppConfig {
    database_url: String,
    port: u16,
    debug_mode: bool,
}

impl AppConfig {
    /// Reads environment variables and constructs the config.
    fn from_env() -> Result<Self, String> {
        // Fail fast if the database URL is missing.
        let db_url = env::var("DATABASE_URL")
            .map_err(|_| "DATABASE_URL is missing".to_string())?;

        // Provide a sensible default for the port.
        let port_str = env::var("PORT").unwrap_or_else(|_| "8080".to_string());
        let port = port_str.parse::<u16>()
            .map_err(|_| "PORT must be a valid number".to_string())?;

        // Parse boolean flags from common string representations.
        let debug = env::var("DEBUG")
            .map(|v| v == "true" || v == "1")
            .unwrap_or(false);

        Ok(Self { database_url: db_url, port, debug_mode: debug })
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Ignore errors if .env is missing. Useful for CI/CD.
    dotenv().ok();

    let config = AppConfig::from_env()?;
    println!("Server starting on port {} with debug={}", config.port, config.debug_mode);
    Ok(())
}

Notice the dotenv().ok() call. In continuous integration pipelines, the .env file often does not exist. The secrets live in the CI dashboard instead. Calling .ok() swallows the FileNotFound error and lets the program continue. The environment variables are already injected by the CI runner, so the fallback works seamlessly.

Convention aside: the Rust community prefers explicit error handling over silent failures. Using .ok() is acceptable here only because missing .env files are expected in production and CI environments. For local development, keeping the ? operator forces you to fix configuration drift immediately.

Testing and global state friction

Environment variables are global state. They live in the process, not in your function scope. This creates friction when you write tests. If one test sets a variable and forgets to clean it up, the next test sees the leftover value. Your test suite becomes order-dependent. Flaky failures appear.

The standard library provides std::env::set_var and std::env::remove_var for testing. You can wrap your test setup in a helper that saves the original value, injects the test value, and restores the original value in a drop guard. This pattern keeps tests isolated.

use std::env;

/// Temporarily overrides an environment variable for testing.
struct EnvOverride {
    key: String,
    original: Option<String>,
}

impl EnvOverride {
    fn new(key: &str, value: &str) -> Self {
        let original = env::var(key).ok();
        env::set_var(key, value);
        Self { key: key.to_string(), original }
    }
}

impl Drop for EnvOverride {
    fn drop(&mut self) {
        match self.original {
            Some(ref val) => env::set_var(&self.key, val),
            None => env::remove_var(&self.key),
        }
    }
}

Use this guard at the start of each test. The compiler guarantees cleanup even if the test panics. Global state does not have to be a minefield if you treat it like a borrowed resource.

Common traps and compiler friction

Environment variables are always strings. The OS does not know about integers, booleans, or JSON. You must parse them yourself. If you pass PORT=abc, parse::<u16>() returns an error. You need to handle it gracefully instead of letting the program panic mid-request.

Another trap is variable expansion. Some tools automatically replace $OTHER_VAR inside values. dotenvy does not do this. If you write API_URL=https://$HOST/api, the literal string $HOST stays in the value. You must expand variables manually or rely on shell expansion before the process starts.

Security is the biggest concern. .env files contain secrets. They must never enter version control. Add .env to your .gitignore file immediately. Provide a .env.example file with placeholder values so new developers know what keys to create. If you accidentally commit a real .env file, rotate those credentials before you push again.

When you try to read a missing variable with env::var(), the function returns a Result. If you ignore the error and call .unwrap(), the program panics with a NotPresent error. The compiler will not stop you from calling .unwrap(), but it will force you to acknowledge the Result type. Treat missing configuration as a fatal startup error. Fail fast. Do not try to recover from a missing database URL at runtime.

Convention aside: the community standard for discarding intentional results is let _ = dotenv().ok();. It signals to readers that you considered the error and chose to drop it. Using .ok() without the underscore triggers a compiler warning. Write the underscore. Make your intent explicit.

When to reach for dotenvy versus alternatives

Use dotenvy when you need a lightweight, zero-dependency way to load local configuration files during development. Use std::env::var alone when your deployment environment already injects variables through Docker, Kubernetes, or a CI dashboard. Use the config crate when you need a full hierarchy that merges defaults, JSON files, environment variables, and command-line flags into a single typed struct. Use a dedicated secrets manager like AWS Secrets Manager or HashiCorp Vault when you are running in production and need automatic rotation, audit logs, and zero local file exposure.

Where to go next