envro 0.10.0

A crate to load environment variables from a .env file into the process environment variables
Documentation

Envro

Crates.io docs.rs

Env vars for Rust: parse .env files, load them into std::env, validate with a composable rule set, and optionally derive a typed Config.

Features

  • Parse a .env file to a HashMap — no side effects on the process.
  • Load a .env file into the process environment with an explicit override policy.
  • Validate env vars against a Schema of composable rules. Validation is fully decoupled from loading: it works on any HashMap, on a .env file, or on the live process environment.
  • Derive a typed Config with #[derive(Envro)] — field types are the coerce targets; #[envro(...)] attrs are the rules. Values still load at runtime.
  • Small .env dialect to support comments, multiline values, quotes and so on.

Getting started

cargo add envro
use std::env;
use envro::{load_dotenv_in_env_vars, Envro, EnvroConfig, EnvroError};

#[derive(Envro, Debug)]
struct Config {
    #[envro(from = "APP_NAME", min_len = 1, max_len = 64)]
    app_name: String,

    #[envro(from = "APP_PORT", port)]
    app_port: u16,

    #[envro(from = "DATABASE_URL", min_len = 1, starts_with = "postgres://")]
    database_url: String,

    #[envro(from = "DB_POOL_SIZE", positive_integer)]
    db_pool_size: i64,

    #[envro(from = "LOG_LEVEL", one_of("debug", "info", "warn", "error"))]
    log_level: String,

    #[envro(from = "FEATURE_METRICS", boolean)]
    feature_metrics: bool,
}

fn main() -> Result<(), EnvroError> {
    let env_file = env::current_dir()?.join(".env");

    // load .env into process env when the file exists (keep existing non-empty values)
    if env_file.is_file() {
        load_dotenv_in_env_vars(&env_file, false)?;
    }

    let config = Config::from_env()?;
    println!("{config:?}");
    Ok(())
}

Loading .env files

Types

  • EnvroVars — alias for HashMap<String, String>
  • EnvroError — errors from envro
    • EnvroError::File — cannot read the file
    • EnvroError::Parse — invalid line (missing =, empty name, unclosed quote, duplicate key, …)
    • EnvroError::Validation — validation issues (see Validation)

load_dotenv(path) -> Result<EnvroVars, EnvroError>

Parses a .env file and returns the variables as a map. Does not change process environment variables. Duplicate keys in the file are rejected.

use std::env;
use envro::*;

let env_file = env::current_dir().unwrap().join(".env");
let vars = load_dotenv(&env_file)?;

assert_eq!(vars.get("DB_POOL_SIZE"), Some(&"32".to_string()));

load_dotenv_in_env_vars(path, override_existing) -> Result<(), EnvroError>

Parses the file and sets process environment variables.

override_existing Behavior
false Keep existing non-empty process values; set unset or empty ones from the file
true Always set values from the file, overwriting existing process values

Keep existing values:

use std::env;
use envro::*;

env::set_var("DB_POOL_SIZE", "8");

let env_file = env::current_dir().unwrap().join(".env");
load_dotenv_in_env_vars(&env_file, false)?;

// process value wins when already set and non-empty
assert_eq!(env::var("DB_POOL_SIZE").unwrap(), "8");

Override existing values:

use std::env;
use envro::*;

env::set_var("DB_POOL_SIZE", "8");

let env_file = env::current_dir().unwrap().join(".env");
load_dotenv_in_env_vars(&env_file, true)?;

// file value wins
assert_eq!(env::var("DB_POOL_SIZE").unwrap(), "32");

Validation

Envro validates env vars with a small, composable rule set. Validation is opt-in and decoupled from loading: the same Schema works on any HashMap<String, String>, on a .env file, or on the live process environment. Envro collects every failing rule and returns them all at once.

At a glance

  • Build a Schema of Field specs, one per env var you care about.
  • Every Field starts as Field::required() or Field::optional(), then chains rules.
  • Envro collects every failing rule across every field before returning a single EnvroError::Validation.
  • Validation is decoupled from where the vars come from: pass a HashMap, load from a .env file, or read directly from the process environment.

Same knobs as Getting started, without the derive:

use envro::*;

let schema = Schema::new()
    .field("APP_NAME", Field::required().min_len(1).max_len(64))
    .field("APP_PORT", Field::required().port())
    .field(
        "DATABASE_URL",
        Field::required().min_len(1).starts_with("postgres://"),
    )
    .field("DB_POOL_SIZE", Field::required().positive_integer())
    .field(
        "LOG_LEVEL",
        Field::required().one_of(&["debug", "info", "warn", "error"]),
    )
    .field("FEATURE_METRICS", Field::required().boolean());

validate_env(&schema)?;

Prefer #[derive(Envro)] when you also want typed fields — see Typed Config. Hand-written Schema stays useful for maps, tests, and validating without a struct.

Sources

Envro exposes three entry points that all share the same Schema:

1. Any HashMap<String, String>validate(&vars, &schema)

Use when your env vars come from a CLI, a config service, test fixtures, or already-loaded values:

use envro::*;

let mut vars = EnvroVars::new();
vars.insert("APP_NAME".into(), "envro".into());
vars.insert("PORT".into(),     "8080".into());

let schema = Schema::new()
    .field("APP_NAME", Field::required().min_len(3).max_len(64))
    .field("PORT",     Field::required().port());

validate(&vars, &schema)?;

2. From a .env file — load_dotenv_validated(path, &schema)

One-shot helper: parse the file, then validate:

use envro::*;

let env_file = std::env::current_dir().unwrap().join(".env");
let vars = load_dotenv_validated(&env_file, &schema)?;

Equivalent to let vars = load_dotenv(&env_file)?; validate(&vars, &schema)?;.

3. Directly on the process environment — validate_env(&schema)

Use when env vars are already set (shell exports, container runtime, systemd unit, env::set_var, tests):

use envro::*;

// vars are already in the process environment
validate_env(&schema)?;

For every key in the schema, validate_env reads std::env::var(key) and applies the field's rules. Missing/errored reads count as "not present" — same semantics as an absent map key.

Types and functions

  • Schema — collection of (name, Field) specs
  • Field — a required/optional field with a chain of rules
  • ValidationIssue — one failure: { key: String, rule: &'static str, reason: String }
  • EnvroError::Validation { errors: Vec<ValidationIssue> } — carries every collected failure
  • validate(&EnvroVars, &Schema) -> Result<(), EnvroError> — validate any map
  • validate_env(&Schema) -> Result<(), EnvroError> — validate the process environment
  • load_dotenv_validated(&Path, &Schema) -> Result<EnvroVars, EnvroError> — load a .env and validate in one call

Semantics

Situation Behavior
Key missing from the map required fails; optional skips remaining rules
Key present with "" Same as missing (empty is treated as absent)
Key present with a value Every rule on the field is checked; all failures are collected
Key in the map but not in the schema Ignored
Multiple failures on one field All collected
Multiple failing fields All collected in one EnvroError::Validation
Length rules (min_len, max_len, exact_len) Count Unicode scalars, not bytes

Presence

Every field starts here. It is the only rule that is not chained.

Field::required()                 // must be present and non-empty
Field::optional().min_len(3)      // if present, must be >= 3 chars

.env view:

# required  -> fails
APP_NAME=

# required  -> passes
APP_NAME=envro

# optional  -> passes (missing entirely, other rules skipped)

Rule reference

Each entry below shows a schema snippet and one .env value that passes and one that fails.

Length / string shape

  • min_len(n) — at least n characters

    Field::required().min_len(3)
    
    APP_NAME=envro   # ok
    APP_NAME=x       # fail: min_len
    
  • max_len(n) — at most n characters

    Field::required().max_len(8)
    
    SLUG=envro        # ok
    SLUG=very-long-x  # fail: max_len
    
  • exact_len(n) — exactly n characters

    Field::required().exact_len(6)
    
    COLOR=ff00aa   # ok
    COLOR=ff00     # fail: exact_len
    
  • alpha() — only alphabetic characters

    Field::required().alpha()
    
    REGION=eu      # ok
    REGION=eu1     # fail: alpha
    
  • alphanumeric() — only letters and digits

    Field::required().alphanumeric()
    
    BUILD=abc123   # ok
    BUILD=abc-123  # fail: alphanumeric
    
  • digits() — only ASCII digits 0-9

    Field::required().digits()
    
    RETRY_COUNT=42   # ok
    RETRY_COUNT=42a  # fail: digits
    
  • ascii() — only ASCII characters

    Field::required().ascii()
    
    USER=alice     # ok
    USER=alicé     # fail: ascii
    
  • lowercase() — no uppercase characters

    Field::required().lowercase()
    
    BUCKET=media   # ok
    BUCKET=Media   # fail: lowercase
    
  • uppercase() — no lowercase characters

    Field::required().uppercase()
    
    REGION_CODE=EU    # ok
    REGION_CODE=Eu    # fail: uppercase
    
  • starts_with(s) — must start with s

    Field::required().starts_with("pk_")
    
    STRIPE_KEY=pk_live_123   # ok
    STRIPE_KEY=sk_live_123   # fail: starts_with
    
  • ends_with(s) — must end with s

    Field::required().ends_with(".env")
    
    CONFIG=prod.env   # ok
    CONFIG=prod.yaml  # fail: ends_with
    
  • contains(s) — must contain s

    Field::required().contains("://")
    
    DB_URL=pg://x   # ok
    DB_URL=no-url   # fail: contains
    
  • one_of(&[..]) — must equal one of the options

    Field::required().one_of(&["debug", "info", "warn", "error"])
    
    LOG_LEVEL=info    # ok
    LOG_LEVEL=trace   # fail: one_of
    
  • not_one_of(&[..]) — must not equal any of the options

    Field::required().not_one_of(&["root", "admin"])
    
    APP_USER=envro   # ok
    APP_USER=root    # fail: not_one_of
    

Numbers

  • integer() — parses as i64

    Field::required().integer()
    
    OFFSET=-3     # ok
    OFFSET=1.5    # fail: integer
    
  • positive_integer()i64 and > 0

    Field::required().positive_integer()
    
    WORKERS=4     # ok
    WORKERS=0     # fail: positive_integer
    
  • non_negative_integer()i64 and >= 0

    Field::required().non_negative_integer()
    
    RETRIES=0     # ok
    RETRIES=-1    # fail: non_negative_integer
    
  • float() — parses as f64

    Field::required().float()
    
    RATIO=0.75   # ok
    RATIO=abc    # fail: float
    
  • positive_float()f64 and > 0.0

    Field::required().positive_float()
    
    RATE=1.5     # ok
    RATE=0.0     # fail: positive_float
    
  • non_negative_float()f64 and >= 0.0

    Field::required().non_negative_float()
    
    RATE=0.0     # ok
    RATE=-0.5    # fail: non_negative_float
    
  • int_range(min, max) — inclusive integer bounds

    Field::required().int_range(1, 100)
    
    PERCENT=50    # ok
    PERCENT=150   # fail: int_range
    
  • float_range(min, max) — inclusive float bounds

    Field::required().float_range(0.0, 1.0)
    
    SAMPLE_RATE=0.25   # ok
    SAMPLE_RATE=2.0    # fail: float_range
    
  • port() — integer in 1..=65535

    Field::required().port()
    
    PORT=8080    # ok
    PORT=70000   # fail: port
    

Boolean

  • boolean() — one of true / false / 1 / 0 / yes / no (case-insensitive)
    Field::required().boolean()
    
    FEATURE_X=true    # ok
    FEATURE_X=YES     # ok (case-insensitive)
    FEATURE_X=on      # fail: boolean (not accepted)
    

Formats (best-effort, std-only)

  • email()local@domain, no whitespace, both sides non-empty. Practical, not RFC 5322.

    Field::optional().email()
    
    ADMIN_EMAIL=ops@example.com   # ok
    ADMIN_EMAIL=ops@              # fail: email (empty domain)
    
  • url() — starts with http:// or https://, remainder non-empty and whitespace-free

    Field::required().url()
    
    WEBHOOK=https://example.com/hook   # ok
    WEBHOOK=ftp://x                    # fail: url (bad scheme)
    
  • uuid() — canonical 8-4-4-4-12 hex form (dashes at positions 8, 13, 18, 23)

    Field::required().uuid()
    
    TENANT_ID=550e8400-e29b-41d4-a716-446655440000   # ok
    TENANT_ID=not-a-uuid                             # fail: uuid
    
  • ipv4() — parses via std::net::Ipv4Addr

    Field::required().ipv4()
    
    BIND=127.0.0.1     # ok
    BIND=127.0.0.256   # fail: ipv4
    
  • ip() — parses via std::net::IpAddr (v4 or v6)

    Field::required().ip()
    
    BIND=::1           # ok
    BIND=not-an-ip     # fail: ip
    
  • hex() — optional 0x / 0X prefix, rest must be non-empty hex digits

    Field::required().hex()
    
    SECRET=0xDEADBEEF   # ok
    SECRET=0xZZ         # fail: hex
    

Lists

list(delim, item) splits the value on delim, trims each part, and applies item's rules to every element. Failing elements report the key as KEY[i] where i is the 0-based position. min_items(n) and max_items(n) chain after list(..) to bound the number of elements.

  • required items, non-empty parts required:

    Field::required().list(',', Field::required().min_len(1))
    
    TAGS=alpha,beta,gamma   # ok
    TAGS=alpha,,gamma       # fail: TAGS[1] required
    
  • optional items, empty parts skipped:

    Field::required().list(',', Field::optional().min_len(2))
    
    T=aa,,cc     # ok (empty middle skipped)
    T=a,bb,cc    # fail: T[0] min_len
    
  • CSV of positive integers, with size bounds:

    Field::required()
        .list(',', Field::required().positive_integer())
        .min_items(1)
        .max_items(5)
    
    PORTS=80,443,8080     # ok
    PORTS=                # fail: PORTS required (list rules skipped when empty)
    PORTS=1,2,3,4,5,6     # fail: max_items
    

Inspecting errors

EnvroError::Validation carries the full list of ValidationIssues, and Display joins them under a VALIDATION_ERROR ... prefix:

match load_dotenv_validated(&env_file, &schema) {
    Ok(vars) => { /* use vars */ }
    Err(EnvroError::Validation { errors }) => {
        for issue in errors {
            eprintln!("{}[{}]: {}", issue.key, issue.rule, issue.reason);
        }
    }
    Err(other) => return Err(other),
}

Example message:

VALIDATION_ERROR PORT[port]: 70000 not in 1..=65535; ADMIN_EMAIL[email]: contains whitespace

Not in v1

Intentionally out of scope for the current rule set:

  • regex / arbitrary predicates
  • filesystem path existence
  • JSON schema, nested maps

.env format

Small, explicit dialect. Values may contain =. Duplicate keys are a hard error. Only ${VAR} is expanded (from other keys in the same file, then the process environment). Bare $ is always literal. Use \${VAR} to keep the braced form without replacement. Unknown, empty, or invalid ${…} becomes an empty string.

Example file:

# comments are ignored
HOST=db.example.com
DB_CONNECTION_STRING=pg://user:pass@${HOST}/mydb
DB_POOL_SIZE=32
EMPTY=
QUOTED="value with spaces"
ESCAPED="say \"hello\""
WITH_EQUALS=host=localhost user=admin
LITERAL=\${HOST}
HASH=$2a$10$abc

Variable substitution

Form Behavior
${NAME} Replaced from other keys in the same file (any order), else from the process environment
${} Replaced with ""
\${} / \${NAME} Literal ${} / ${NAME} (skip replacement)
Unknown / invalid ${…} Replaced with ""
$NAME / $2a$… Always literal — bare $ is a normal character

NAME must match [A-Za-z_][A-Za-z0-9_]*. Expansion is order-independent: all keys are parsed first, then ${VAR} refs are resolved across the file (and the process env) in as many passes as needed. Circular references resolve to empty strings. There is no ${NAME:-default} syntax.

Compose values, validate the parts

Store each knob as its own env var, compose derived values with ${VAR}, and validate every part with a Schema. That keeps rules close to the data (length, port, allow-list, …) instead of parsing a blob at runtime. Definition order does not matter.

Example — Postgres URI from validated parts

.env:

PG_USER=app
PG_PASS=secret
PG_HOST=db.example.com
PG_PORT=5432
PG_DB=mydb
PG_SSLMODE=require

DATABASE_URI=pg://${PG_USER}:${PG_PASS}@${PG_HOST}:${PG_PORT}/${PG_DB}?sslmode=${PG_SSLMODE}

After load, DATABASE_URI is pg://app:secret@db.example.com:5432/mydb?sslmode=require.

use envro::*;

let schema = Schema::new()
    .field("PG_USER", Field::required().min_len(1).max_len(63).alphanumeric())
    .field("PG_PASS", Field::required().min_len(6))
    .field("PG_HOST", Field::required().min_len(1).max_len(253))
    .field("PG_PORT", Field::required().port())
    .field("PG_DB",   Field::required().min_len(1).alphanumeric())
    .field("PG_SSLMODE", Field::required().one_of(&["disable", "require", "verify-full"]))
    .field(
        "DATABASE_URI",
        Field::required().min_len(1).starts_with("pg://"),
    );

let env_file = std::env::current_dir().unwrap().join(".env");
let vars = load_dotenv_validated(&env_file, &schema)?;

let uri = vars.get("DATABASE_URI").unwrap();

See example/ for a runnable version of this pattern.

Valid rows

Row Parses to Notes
# any text (skipped) Full-line comment
(empty line) (skipped) Blank lines are ignored
NAME=envro NAME = envro Basic KEY=value
EMPTY= EMPTY = "" Empty value, no quotes
EMPTY="" EMPTY = "" Empty quoted value
QUOTED="a b" QUOTED = a b Double-quoted value
ESCAPED="say \"hi\"" ESCAPED = say "hi" \" escapes an inner quote
DSN=host=db user=admin DSN = host=db user=admin = allowed inside value
HOST=h / URL=${HOST} (any order) URL = h Order-independent ${VAR}
A=abc${B} / B=123 A = abc123 Forward refs resolve
BARE=$HOST BARE = $HOST Bare $ never expanded
LIT=\${HOST} LIT = ${HOST} \${…} skips replacement
X=${} X = "" Empty braces → empty
X=\${} X = ${} \${} skips replacement
X=${MISSING} X = "" Unknown ${VAR} → empty
X=${1} X = "" Invalid name → empty
HASH=$2a$10$abc HASH = $2a$10$abc $ allowed as a normal char
URL="pg://u:p@h/db" URL = pg://u:p@h/db Any chars fine inside quotes
KEY="line1line2" KEY = line1\nline2 Multi-line quoted value — newlines preserved

Multi-line values

Double-quoted values may span multiple physical lines. When a value opens with " and does not close on the same line, envro keeps reading lines (joining them with \n) until it finds a line ending with an unescaped ". Lines inside the quotes are taken literally — blank lines and # at the start of a line are part of the value, not comments. CRLF endings are normalized to \n inside the value.

PEM="-----BEGIN PRIVATE KEY-----
MIIBVwIBADANBgkqhkiG9w0BAQEFAA...
-----END PRIVATE KEY-----"

Escapes inside quotes: \" — a literal "; a trailing \" on a line therefore does not close the value.

Invalid rows

Row Error
NAME value PARSE_ERROR ... missing value (no =)
=value PARSE_ERROR ... missing variable name
KEY="unclosed PARSE_ERROR ... missing closing quote
KEY=a + KEY=b (same file) PARSE_ERROR ... duplicate variable name: KEY

Anything a .env file rejects surfaces as EnvroError::Parse; unreadable / missing files surface as EnvroError::File. See Validation for EnvroError::Validation.

Typed Config (#[derive(Envro)])

Single source of truth: Rust field types are the coerce targets; #[envro(...)] attributes are the validation rules. Values are never baked into the binary — they still load at runtime from a .env file, a map, or the process environment.

Supported field types: String, bool, i32, i64, u16, u32, u64, f32, f64, and Option<T> of those (optional presence).

Default env key: screaming-snake of the field name (retry_countRETRY_COUNT). Override with from = "KEY".

use envro::{Envro, EnvroConfig};

#[derive(Envro)]
struct Config {
    #[envro(from = "APP_NAME", min_len = 3, max_len = 64)]
    app_name: String,

    #[envro(from = "PORT", port)]
    port: u16,

    #[envro(from = "OFFSET", integer)]
    offset: Option<i64>,

    #[envro(from = "FEATURE_X", boolean)]
    feature_x: bool,
}

fn main() -> Result<(), envro::EnvroError> {
    let config = Config::from_dotenv(&std::env::current_dir()?.join(".env"))?;
    // or: Config::from_vars(&vars)?;  Config::from_env()?;
    println!("{} listens on {}", config.app_name, config.port);
    Ok(())
}

Generated API (via EnvroConfig):

  • Config::schema() -> Schema
  • Config::from_vars(&EnvroVars) -> Result<Self, EnvroError>
  • Config::from_dotenv(&Path) -> Result<Self, EnvroError>
  • Config::from_env() -> Result<Self, EnvroError>

Common #[envro(...)] rules mirror Field: flags such as port, boolean, integer, positive_integer, email, … and keyed forms min_len = n, max_len = n, starts_with = "...", one_of("a", "b"), int_range(1, 100), etc. The hand-written Schema / Field API remains fully supported.

Out of scope

Features not implemented by design:

  • No multi-file layering — composable configs are avoided; one path per call. Follows the “No-Inheritance” Flat principle (CUE on inheritance, Angular LIFT Flat).
  • No macros that bake env values into the binary#[derive(Envro)] encodes types and rules only; .env / process values are always read at runtime. Same build, different env files or containers.

TODO

  • encryption
  • performance
    • proper parsing

LICENSE

MIT License

Copyright (c) 2024-2026 Simone Sanfratello

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.