Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

adotenv

adotenv is a literal, lossless .env parser and editing library for Rust.

It is built for linters, formatters, editors, scanners and codebase maintenance tools that need to inspect source exactly as written.

What it provides

  • Literal values with no implicit expansion or command execution.
  • Byte-for-byte source retention, including comments, invalid statements, BOM and mixed line endings.
  • Precise source spans and checked UTF-8, scalar and UTF-16 positions.
  • Verified edits, formatting, sorting, comparison and example generation.
  • Five syntax presets and explicit reference resolution.
  • Optional file loading with values, origins and reports.
  • No required runtime dependencies and a no_std build with alloc.

Install

[dependencies]
adotenv = "0.10.4"

Enable optional features as needed:

adotenv = { version = "0.10.4", features = ["load", "serde"] }

Quick start

#![allow(unused)]
fn main() {
let text = "# app\nexport PORT=8080\nURL=\"${HOST}/api\"\n";
let doc = adotenv::parse_strict(text).unwrap();

assert_eq!(doc.get("PORT"), Some("8080"));
assert_eq!(doc.get("URL"), Some("${HOST}/api"));
assert_eq!(doc.to_string(), text);

let edited = doc.set("PORT", "9090").unwrap();
assert_eq!(
    adotenv::parse_strict(edited.as_str()).unwrap().get("PORT"),
    Some("9090")
);
}

When only final values are needed, use the values-only parser:

#![allow(unused)]
fn main() {
let values = adotenv::parse_values("A=1\nA=2\nB=3\n").unwrap();
assert_eq!(values.get("A").map(String::as_str), Some("2"));
}

Documentation

Source code and issues: github.com/asamarts/adotenv.

Architecture

adotenv separates syntax from meaning.

Document model

Document borrows or owns one UTF-8 source string and a dialect. It stores:

  • items in source order, including comments, blank lines and invalid text;
  • entries that the selected dialect recognizes;
  • raw token spans for keys, separators, values and comments;
  • diagnostics produced during parsing;
  • a line index built on demand.

Item covers comments, blank lines, declarations and invalid or ignored statements. Entry is a declaration view. Both are zero-based snapshot IDs.

The parser never edits source text. Printing a document returns the original bytes unchanged.

Recovering and strict parsing

parse and parse_owned always return a document plus diagnostics. They recover at the smallest safe boundary and preserve the source that caused a problem. parse_values and parse_values_with are the values-only path. They use the projection parser, discard warnings and return the first error.

parse_strict returns the first error. Warnings and informational diagnostics do not fail a strict parse.

Diagnostics contain a stable code, severity, message, primary span and optional secondary labels. Message text may change; codes and spans are the stable contract.

Meaning and lookup

A dialect decides which text counts as a declaration and how it is decoded. Lookup follows the dialect’s duplicate policy and participation rules.

  • get returns the effective value.
  • get_entry returns the effective declaration.
  • get_all returns every occurrence.
  • unique rejects duplicate keys.
  • effective_entries yields one winning declaration per effective key.
  • lookup_index builds an explicit index for repeated lookups.

Bare declarations are distinct from empty assignments. KEY has no value, KEY= has an empty value. BareKeys::Empty changes effective lookup without changing the declaration’s syntax state.

Spans and positions

Source spans are byte ranges into the original input. Token helpers return raw ranges. Decoded value offsets can be mapped back to raw ranges when escapes are present.

LineIndex converts between offsets and line/column positions. Encodings are explicit: UTF-8 bytes, Unicode scalars or UTF-16 code units. Invalid positions return an error. They are never rounded or clamped.

Entry and diagnostic lines are one-based. Position is zero-based.

Verified edits

Edits are built as text replacements against the original snapshot. Before an edit is returned, the complete result is parsed with the same dialect and checked for changes to unrelated declarations, comments, item kinds and invalid statements. If a change cannot be verified, the edit fails.

VerifiedEdits exposes borrowed text edits, the verified output and optional metadata. Apply edits from the highest offset to the lowest.

Features and safety

The core has no runtime dependencies and no I/O. load enables filesystem loading. serde enables typed deserialization. exec enables a command runner with an explicit working directory.

The core forbids unsafe code. Process environment mutation lives in the separate adotenv-process crate and is an unsafe API because the environment is global.

Disable default features for no_std with alloc. The parser, model, edits, formatting, comparison and sorting remain available.

Dialects

There is no single .env standard. adotenv makes the reading rules explicit.

Presets

PresetSyntax
Dialect::PORTABLEStrict, literal default for mixed tooling
Dialect::NODE_DOTENVNode dotenv default regex parser
Dialect::PYTHON_DOTENVPython dotenv literal text-stream parser
Dialect::COMPOSECompose env-file statement syntax without interpolation
Dialect::DOCKER_RAWDocker CLI raw key/value parser

Presets choose parsing rules only. None enables interpolation, inheritance or file loading. Compose bare-key roles and lookup remain the caller’s job.

Portable rules

  • Keys use ASCII letters, digits, _, . and -. A non-POSIX key is accepted with a warning. Other characters are invalid.
  • An optional export prefix and spaces or tabs around = are accepted.
  • # starts a comment at the beginning of a line or after whitespace. A=a#b has value a#b and a warning. A= # comment is assigned empty.
  • Single quotes are literal. Double quotes decode \n, \r, \t, \\, \" and \$. Unknown escape pairs stay literal with a warning.
  • Single and double quoted values may span lines. Original line endings remain.
  • Text after a closing quote is an error unless it is a comment.
  • KEY is a bare declaration with no value. KEY= and KEY="" have an empty value. Bare declarations never read the environment.
  • Duplicates are retained. The last declaration wins and a warning is emitted. A last bare declaration does not fall back to an earlier value.
  • A leading BOM is retained as a separate prelude. LF, CRLF and lone CR are preserved. Lone CR produces a warning.
  • Backticks are literal text, not quotes.
  • Parsing never expands variables or runs commands.

Custom policies

Start from a preset and use builders:

#![allow(unused)]
fn main() {
use adotenv::{BareKeys, Dialect, InlineComments};

let dialect = Dialect::NODE_DOTENV
    .with_inline_comments(InlineComments::None)
    .with_bare_keys(BareKeys::Ignore);
}

Policy groups cover:

  • statement grammar and key characters;
  • comments, quoting, escapes and raw values;
  • quoted-key support and multiline values;
  • bare declarations and duplicate lookup;
  • BOM handling, line breaks and whitespace classes;
  • quoted tails, malformed lines, Node object-key rules and line-byte limits.

Each document keeps its exact configuration. Document::reparse rebuilds items, entries and maps for a different dialect while reusing the source text. Snapshot IDs belong to one interpretation only.

Preset behavior

Node supports backticks, colon separators after whitespace, decoded newline normalization and its default regex fallback. __proto__ is retained as source but excluded from effective object lookup.

Python supports its named escape set, multiline single-quoted keys and Python whitespace classes. Original CRLF bytes stay in text-stream values.

Compose recognizes Unicode letter and number categories, Compose whitespace and octal escapes. Text after a quoted value can begin another statement. Its EOF bare-key behavior is retained. Dollar syntax stays literal.

Docker keeps values, hashes, quotes and internal CR bytes raw. Bare keys are retained but excluded from lookup. The scanner enforces the upstream line-byte limit and only ASCII space or tab invalidates a nonempty raw key.

Encoding

encode_value_with(value, dialect) returns one literal token or EncodeError::Unrepresentable. Success is checked in both A=<token>\n and EOF A=<token> contexts. The token does not account for arbitrary neighboring syntax or for consumers that enable interpolation.

encode_value(value) is the Portable helper. It rejects controls other than CR, LF and tab.

Portability

Document::portability(&[dialects]) reparses the retained source for each unique configuration and reports lines whose meaning differs. The document’s own dialect is always the first reading.

Each reading exposes ordered parts: declarations, blank lines, comments, invalid or ignored statements and multiline continuations. Declaration parts include key, decoded value, assignment state, export prefix, lookup participation, effective value and duplicate winner. Quote style alone is not a difference.

The check is literal. It performs no expansion, lookup, inheritance or I/O.

References and resolution

Finding a reference and resolving it are separate operations.

Reference inspection

Entry::references() returns raw candidates in source order:

  • $VAR and ${VAR};
  • ${VAR:-default}, ${VAR-default}, ${VAR:?message}, ${VAR?message}, ${VAR:+alternative} and ${VAR+alternative};
  • $(command), including nested payloads.

Each Reference records its kind, full span, quote and escape context, nesting, closing delimiter state and contained forms. Variable forms expose name and operator spans plus arguments. Command forms expose the raw payload span.

Single-quoted, escaped-dollar and malformed candidates remain visible. Visibility does not make a reference eligible.

Reference::is_eligible(ReferenceRules) is a pure policy check. Rules select operators, single-quote behavior, escaped dollars, backticks and commands. Nested references require every containing form to be eligible.

Inspection performs no lookup, expansion, I/O or command execution.

Resolution

Document::resolve(lookup, options) substitutes eligible references using file bindings and a caller closure. The core never reads the environment or files.

let resolved = doc.resolve(
    |key| lookup.get(key).cloned(),
    &ExpandOptions::portable(),
)?;

Caller values are literal and are not expanded again.

ExpandOptions selects:

OptionChoices
Operatorsnone, braced only, basic, Compose or dotenv-expand style
Lookup ordercaller first or file first
Scopesequential or whole-file
Unresolved variablesempty, kept literal or error
Quote and escape rulessingle quotes, escaped dollars, backticks
Limitsnesting depth and expanded bytes per value

Sequential scope uses the latest earlier binding. Whole-file scope uses the final binding. With no caller seed, A=base followed by A=${A}x gives basex in sequential scope and a cycle in whole-file scope.

Every resolved value keeps its origin: file, span and key, or caller lookup. Nested file provenance is retained.

Resolution reports required variables, cycles, depth and size limits as structured errors. It never loops on cycles.

Commands

Command forms stay literal unless the caller supplies a callback and the rules enable commands.

Document::resolve_with_commands passes each command to the callback with:

  • the command text as written;
  • the full reference and payload spans;
  • an optional caller source identity.

Output is literal and is not expanded again. Every call is recorded, including failed calls. Earlier calls are not rolled back when a later resolution error occurs.

The optional exec feature provides exec::Shell. It needs an explicit working directory, runs through the platform shell, trims one trailing newline and never uses the host working directory as a fallback. Only use it with trusted input.

Formatting and sorting

Both operations return verified edits against the original snapshot.

Format

Document::format changes selected trivia only:

  • spacing around assignment separators;
  • trailing spaces and tabs;
  • blank-line runs;
  • quote style when it can be changed without changing the value;
  • the final newline.
#![allow(unused)]
fn main() {
use adotenv::{parse_strict, FormatOptions};

let source = "# app   \n\nA=1   \nB =  2 # note   \n";
let doc = parse_strict(source).unwrap();
let formatted = doc.format(&FormatOptions::normalize()).unwrap();
assert_eq!(formatted.as_str(), "# app\n\nA = 1\nB = 2 # note\n");
}

The complete output is parsed again with the same dialect. Formatting fails if it changes declarations, comment attachment, item kinds or malformed statements.

Options include EqualsSpacing, BlankLines, QuoteStyle, FinalNewline and FormatOptions::normalize_groups.

Quote changes are accepted only when the decoded value remains exact. Empty assigned values keep their quote style. Raw-value dialects, multiline bodies, invalid statements, empty assignments that could become indentation and mixed lone-CR boundaries are preserved.

Sort

Document::sort orders declarations inside each blank-line group.

  • Comments above the first entry stay as a group header.
  • Comments above later entries move with those entries.
  • Inline comments and multiline values move intact.
  • References inside a group are ordered before their dependents.
  • Duplicate keys, empty keys, malformed items and dependency cycles leave the group unchanged.
#![allow(unused)]
fn main() {
let source = "# header\nB=${A}\n# A note\nA=1\n# bottom\n";
let doc = adotenv::parse_strict(source).unwrap();
assert_eq!(
    doc.sort().unwrap().as_str(),
    "# header\n# A note\nA=1\nB=${A}\n# bottom\n"
);
}

Sorting does not format spacing, expand values, read files or inspect the environment. Use format separately when both changes are wanted.

Comparison and examples

Compare documents

Document::compare reports effective-key differences between two documents. Each document uses its own dialect for duplicates and lookup participation.

Difference kinds:

  • value changed;
  • assignment state changed, such as assigned, empty or bare;
  • key exists only on the left;
  • key exists only on the right;
  • export state changed.
#![allow(unused)]
fn main() {
use adotenv::{parse_strict, DifferenceKind};

let left = parse_strict("A=1\nB=\nC=3\n").unwrap();
let right = parse_strict("A=2\nB\nD=4\n").unwrap();
let changes: Vec<_> = left
    .compare(&right)
    .differences()
    .iter()
    .map(|entry| (entry.key.as_str(), entry.kind))
    .collect();

assert_eq!(
    changes,
    [
        ("A", DifferenceKind::Value),
        ("B", DifferenceKind::State),
        ("C", DifferenceKind::OnlyLeft),
        ("D", DifferenceKind::OnlyRight),
    ]
);
}

Comparison is literal. It does not expand values, read files, inspect the environment or run commands.

Generate an example

Document::example replaces assigned values while preserving:

  • comments and blank lines;
  • keys, export prefixes and formatting;
  • line endings and a BOM;
  • the assignment state of bare declarations.

Malformed statements and empty-key entries become indentation-preserving comments. Value text from those forms cannot leak into the output.

#![allow(unused)]
fn main() {
let source = "# app\nA=1\nB=\"two\" # note\nBARE\nBAD KEY=secret\n";
assert_eq!(
    adotenv::parse(source).0.example(),
    "# app\nA=\nB= # note\nBARE\n#\n"
);
}

Empty quoted values keep a placeholder when needed to keep an inline comment attached. The result is intended for examples and documentation, not for use as a runtime configuration.

Loading

The load feature adds strict filesystem loading. Parsing itself never reads files.

Cascades

load_cascade(directory, mode) reads, in order:

  1. .env
  2. .env.local
  3. .env.{mode}
  4. .env.{mode}.local

A missing cascade file is skipped. A file that exists but cannot be read, decoded as UTF-8 or parsed invalidates the whole load. Later files override earlier files by effective key.

load_files(paths, dialect) reads an explicit ordered list. Every listed file must exist.

Environment data

LoadedEnvironment stores:

  • final values, sorted by key;
  • shared-file origins with path, value span and line;
  • raw winning tokens for contextual resolution;
  • optional reports for entries, overrides, repeats and skipped declarations.

The default profile keeps values, origins and raw tokens. Reports are off.

use adotenv::load::load_cascade;

let loaded = load_cascade("/srv/my-app", "production")?;
assert_eq!(loaded.get("PORT"), Some("8080"));

let origin = loaded.origin("PORT").unwrap();
println!("{}:{}", origin.path().display(), origin.line());

Values and report views borrow the environment:

get(key)                 Option<&str>
values()                 Iterator<Item = (&str, &str)>
origin(key)              Option<LoadOrigin<'_>>
entries()                Iterator<Item = LoadedEntry<'_>>
overrides()              Iterator<Item = LoadedOverride<'_>>
repeated()               Iterator<Item = LoadRepeated<'_>>
skipped()                Iterator<Item = LoadSkipped<'_>>
into_values()            LoadedValues

LoadOrigin<'_> exposes path(), span() and line(). Entry, override, repeat and skipped views expose accessors rather than public fields.

Options

LoadOptions::new(dialect) is the default. LoadOptions::full(dialect) enables all reports. Builders control each group:

BuilderEffect
with_raw_tokens(false)Drop raw tokens; resolve_contextual returns an error
with_reports(false)Leave report iterators empty
with_origins(false)Drop final origins and override reports

With reports enabled, entry records share keys and values with the final value table. Reports do not duplicate final values.

Resolution

resolve_contextual reconstructs each winning raw token, reparses it with the load dialect and resolves references. Quote, escape and dialect context survive the merge. It requires raw-token retention.

resolve_decoded treats every final value as unquoted decoded text. It requires whole-file resolution scope.

Both methods return ResolvedEnvironment. Origins are available through origin(key). Command substitution remains literal unless a caller callback is provided through the document resolver.

Values only

load_cascade_values and load_values retain only final strings. They use the same projection path as core parse_values and do not keep origins, raw tokens, reports or a lossless document.

use adotenv::{load::load_cascade_values, Dialect};

let values = load_cascade_values("/srv/my-app", "production")?;
assert_eq!(values.get("PORT"), Some("8080"));
Ok::<(), Box<dyn std::error::Error>>(())

Errors

LoadError distinguishes:

  • I/O failure with path and OS error;
  • invalid UTF-8;
  • the first syntax or policy error with path and diagnostic;
  • an invalid mode name.

Mode names may not contain path separators, . or ...

Memory

Measured on a release build with one 65,536-entry file:

ProfilePeakRetained
Default47.7 MB28.0 MB
LoadOptions::full57.4 MB37.7 MB
Values only11.2 MB10.0 MB

The default loader keeps one record per key for its value, origin and raw token. Budgets and the full benchmark method are in performance.md.

Serde

The serde feature adds typed deserialization for documents and loaded environments.

adotenv = { version = "0.10.4", features = ["serde"] }

Documents

use serde::Deserialize;

#[derive(Deserialize)]
struct Config {
    host: String,
    port: u16,
}

let doc = adotenv::parse_strict("host=localhost\nport=8080\n").unwrap();
let config: Config = doc.deserialize()?;

Keys follow the document’s duplicate policy and lookup participation. Bare declarations contribute an empty string only under BareKeys::Empty.

Loaded environments

With load and serde enabled, LoadedEnvironment::deserialize uses the final merged values. Origin lines remain available for conversion errors.

let loaded = adotenv::load::load_cascade("/srv/my-app", "production")?;
let config: Config = loaded.deserialize()?;

Conversion rules

Values are decoded literals before Serde sees them. The adapter converts scalars:

  • strings;
  • booleans;
  • integers and floats;
  • characters.

It does not invent nested maps, sequences or enum layouts. Deserialize a string and convert explicitly when a field needs another shape.

DeserializeError reports the key and source line when an origin is available.

CLI

adotenv-cli provides command-line tools built on the library APIs.

Commands

adotenv-cli [--dialect portable|node|python|compose|docker] <command> ...

get <file> <key>
check [--format human|json|github] [--source] [--recursive] [--exclude <path>] <path>...
fmt [--write|--check] [--sort] [--recursive] [--exclude <path>] <path>...
diff [--format human|matrix|json] [--json] <baseline> <file>...
example <file>
load <directory> [mode]
set <file> <key> <value>
run <directory> [mode] -- <program> [args...]

check reads one or more files or directories. fmt formats, checks or writes in place. diff treats the first file as the baseline. run loads a cascade and starts a child process with those values. The parent environment is not changed.

Diagnostics

Human output contains path, one-based line and column, severity, code, message and a summary. Values are not printed by default because they may be secrets. --source adds source lines and carets for human output only.

check --format json uses this machine schema:

{"version":1,"files":[{"path":"...","diagnostics":[]}]}

The exact fields, diagnostic codes, severities and spans are stable contracts.

check --format github emits GitHub Actions workflow annotations. The field names and escaping are stable.

diff --format json emits one result per candidate file. The versioned schema is stable. Human tables are not a machine contract.

Discovery

check --recursive and fmt --recursive find .env and .env.* below each root. With no path they use ..

--exclude is repeatable and relative to each root. Discovery skips .git, target, node_modules and symbolic links.

Input and output

File-reading commands accept - or --stdin for standard input. fmt prints formatted text unless --write or --check is selected.

On Unix, --write uses a same-directory atomic rename. On Windows, it writes a temporary file and attempts a direct replace. If that fails, it moves the old file to a backup, installs the new file and restores the backup on error. The Windows fallback is not crash-atomic.

Exit codes

  • 0: success or no differences.
  • 1: differences, diagnostics or a required formatting change.
  • 2: usage, read or write failure.

diff exits 1 when any candidate differs. fmt --check exits 1 when a file would change. run returns the child exit code.

Limits

Command substitution from loaded files is disabled. Use the library or the adotenv-process crate for other workflows.

Integrations

config

adotenv-config exposes document values as a config source.

use adotenv_config::AdotenvSource;

let doc = adotenv::parse_strict("host=localhost\nport=8080\n").unwrap();
let config = config::Config::builder()
    .add_source(AdotenvSource::from_document(&doc))
    .build()?;

The adapter follows config key rules: names are case-insensitive and dots create nested paths. Its load feature adds from_environment for LoadedEnvironment.

config can deserialize typed structures with its own scalar coercion.

Figment

adotenv-figment exposes a Profile::Default dictionary with original key spelling.

use adotenv_figment::AdotenvProvider;

let doc = adotenv::parse_strict("host=localhost\nport=8080\n").unwrap();
let figment = figment::Figment::new().merge(AdotenvProvider::from_document(&doc));

Figment values remain strings. Deserialize string fields and convert explicitly.

The load feature adds from_environment for loaded environments.

Shared rules

Both adapters use the document or environment’s duplicate policy, lookup participation and bare-key behavior. They do not expand references, read files or mutate the process environment.

Quality

Test layers

The workspace uses:

  • unit tests for parser, model, edits, encoding, formatting, sorting, comparison and positions;
  • property tests for arbitrary UTF-8 parsing, edits, encoding and comparison;
  • integration tests for loading, serde, CLI schemas and adapter behavior;
  • compile tests for no_std, MSRV and feature combinations;
  • doctests for public examples.

Run the main gate:

cargo fmt --all --check
cargo test --workspace --all-features --locked
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --all-features --locked
cargo +1.81 build -p adotenv-cli --locked
python3 conformance/check_adotenv.py --setup
cargo package -p adotenv --locked

Feature checks cover default, load, load,serde, no-default alloc and the thumbv7em-none-eabi target.

Diagnostics

Recovering parsers retain items and diagnostics. Strict parsing and native edit verification avoid building diagnostic lists that they do not use.

Diagnostic codes and spans are stable. Message text may change.

Fuzzing

The workspace has five scheduled fuzz targets:

  • parser;
  • edits;
  • encoding;
  • resolution;
  • formatting.

Each target has a public seed corpus. Scheduled runs use bounded time and memory. Manual campaigns are useful during development but do not replace the scheduled coverage used for release evidence.

The fuzz crate also has a local differential target for the simple Portable subset against dotenv-compat:

cd fuzz
cargo +nightly fuzz run --target x86_64-unknown-linux-gnu differential

This target is for local investigation and is not part of the five-target scheduled evidence.

Run one target with:

cargo fuzz run parser

Conformance

conformance/manifest.json records pinned observations from real Node, Python, Compose and Docker env-file APIs. The current corpus has 80 cases and 20 profiles. The runner checks package and source identities, hashes inputs and compares only observations that map to the literal adotenv model.

python3 conformance/check_adotenv.py --setup

Compose’s default parser performs interpolation, so only dollar-free inputs with an empty lookup are literal comparison oracles. Portable is not asserted equal to any upstream implementation.

Performance

Performance tests are local and use fixed inputs. CI runs smoke benchmarks only. The comparison rules and current results are in performance.md.

MSRV and semver

The MSRV is Rust 1.81. It rises only in a minor release.

The public API follows semver. New variants and fields use non-exhaustive types and builders. A breaking release is announced with its compatibility details in the release notes.

Limits

A successful bounded test or fuzz run is not a proof for all inputs. Callers handling untrusted source should set byte, line and declaration budgets before parsing. Retained documents, decoded maps and requested diagnostics consume memory.

Performance

These are local reference measurements, not CI thresholds.

Method

  • Reference host: kbench, Intel Core i7-6700HQ, 4 cores and 8 threads, 2.6 GHz, Linux x86_64, rustc 1.97.0. The benchmark process was pinned to one CPU.
  • Release build with a fixed fixture generator.
  • cargo bench -p adotenv --bench core --locked -- --sample-size 20 --measurement-time 2
  • The host was otherwise idle. Wall time is recorded; instruction counts are the stable CPU comparison for cross-library work.

Core workloads

Workload8 entries128 entries65,536 entries
parse6.29 us99.8 us62.4 ms
lookup index build136.6 ns2.36 us3.40 ms
indexed get, 64 keys195.8 ns1.63 us1.76 us
portability, 4 presets62.8 us1.09 ms745 ms
references507.6 ns7.94 us4.64 ms
resolve7.23 us173.9 us118.9 ms
sort3.19 us278.6 us161.1 ms
format normalize8.30 us135.8 us76.7 ms
native set10.4 us102.4 us61.7 ms
native batch8.33 us102.9 us63.6 ms

Document::get scans entries. For many lookups, build Document::lookup_index once. The index uses a compact open-addressed table with an ordered fallback for collision-heavy input.

Parser memory

One 65,536-entry document with a 1.8 MB source:

  • live after parse: 23.6 MB;
  • peak parse: 32.0 MB;
  • parse plus collected map: 4.6 MB live after the document is dropped.

Retained document memory is dominated by item and token metadata. Values-only loading avoids the document entirely.

Loading memory

One 65,536-entry file on a release build:

ProfilePeakRetained
Default47.7 MB28.0 MB
LoadOptions::full57.4 MB37.7 MB
Values only11.2 MB10.0 MB

The default keeps values, shared-file origins and raw tokens. Reports add entry, override, repeat and skipped vectors. Values-only loading projects values while parsing and retains no document or provenance.

The default profile now stores value, origin and raw-token data in one record per key. This removes two per-key maps from the retained loader.

Budgets

The comparison harness is the source of these budgets. Reference results are recorded on the kbench host. Run the full check with:

python3 scripts/compare.py --check-budgets
ProfileInstructionsPeak RSSRetained RSS
adotenv parse260Mnot budgeted29 MB
adotenv parse + values400M12 MB12 MB
adotenv parse + map335Mnot budgeted16 MB
values-only load400M13 MB11.5 MB
default load560M50 MB30 MB
full-report load750M60 MB40 MB

The parse-only binary budget is 500 KB. Wall time is recorded but not enforced because the shared host is too noisy for a hard threshold.

A release blocks above the instruction or memory budget until the regression is fixed or explicitly accepted. A wall-time result between 1.5x and 2x requires a repeat run and an explanation; above 2x blocks the release.

Diff and matrix

For three 65,536-entry files:

  • streaming two-file diff with indexed comparison: about 73.3 MB peak and 0.6 s;
  • matrix diff with compact incremental rows: about 103.0 MB peak and 0.8 s.

Matrix output retains one row per key because every column is needed. Two-file diff streams candidates and buffers output until all candidates validate.

Comparison with other parsers

The closest comparison is adotenv::parse followed by collecting effective values. Other crates return maps or load into the environment directly.

Fixture: 65,536 compatible declarations, 1.8 MB source.

ImplementationInstructionsLive memoryPeak
adotenv parse272M23.6 MB32.0 MB
adotenv parse + map377M4.6 MB32.0 MB
dotenvy iterator + Vec373M5.0 MB15.7 MB
dotenv-compat parse307M9.5 MB18.6 MB
dotenv-ng input-only load406M7.9 MB20.4 MB
dotenv-parser map1,488M7.1 MB50.1 MB

adotenv retains more syntax than the other parsers, including spans, items and diagnostics. Values-only loading removes that overhead when it is not needed.

Budget

  • CI runs benchmark smoke only.
  • Compare repeated runs on the same host and toolchain.
  • A 1.5x deviation needs a repeat run and an explanation.
  • A 2x deviation blocks a release until it is explained or fixed.
  • Update this file when fixtures, compiler major version or methodology change.

Benchmark program

The goal is to measure adotenv against the main Rust .env parsers with reproducible evidence and to keep adotenv at the front of the comparison on material metrics.

Quiet host

Use kbench through SSH for the reference run.

  • Intel Core i7-6700HQ, 4 cores and 8 threads, 2.6 GHz
  • 15 GiB RAM
  • Linux x86_64
  • rustc and cargo from the stable toolchain
  • hyperfine and valgrind installed
  • Record load average, hostname, rustc version and fixture hashes with every run

Run on an otherwise idle host. Do not use shared-runner wall time as a release threshold.

Workstreams

1. Harness and reproducibility

  • Standalone comparison crate with pinned competitor versions.
  • Simple 128-entry and 65,536-entry fixtures.
  • Mixed fixture with quoting, spacing, Unicode, comments and assigned values.
  • Wall time, CPU time, callgrind instructions, peak RSS, retained RSS and stripped binary size.
  • One-command report generation and optional document update.
  • Budget check for selected profiles.
  • Record fixture hashes and host metadata in the JSON result.
  • Add a real-world corpus from representative public .env files.
  • Add a per-dialect adotenv matrix.

2. Comparable output

  • Report native-map and values-only paths separately.
  • Record hash-map versus ordered-map costs as an API tradeoff: adotenv exposes ordered maps; competitors expose unordered maps.
  • Add loader profiles where the competitors expose them; comparable stream profiles do not exist.
  • Exclude or clearly label non-comparable APIs.

3. Engineering metrics

  • Measure clean release build time for each parser demo.
  • Record binary size per feature set.
  • Record dependency count, MSRV and build-script requirements.
  • Record edit, format, sort and compare throughput for adotenv-only workloads where no competitor equivalent exists.

4. Optimization

  • Parse throughput.
  • Values-only throughput and memory.
  • Map build throughput and memory.
  • Default loader and full-report loader memory.
  • Parse-only binary size.
  • Startup time for the CLI.

5. Reporting

  • Generate a benchmark page for the documentation site.
  • Include method, host, versions, raw JSON links and budgets.
  • Record repeated runs and median; raw hyperfine JSON retains the sample set.
  • Keep historical results out of the current docs; store raw artifacts separately.

6. Release gates

  • Normal CI runs benchmark smoke only.
  • Manual budget check exists.
  • Run the complete comparison on kbench before a performance claim or a 1.0 release.
  • Compare against the previous accepted baseline and require an explanation for a 1.5x to 2x regression.
  • Block above 2x until fixed or explicitly accepted.

Current reference run

The current reference run uses the simple 128-entry and 65,536-entry fixtures, the mixed fixture, CPU time, callgrind instructions, RSS and parse-only binary sizes. The comparison crate remains outside the published package and the normal workspace.

The benchmark matrix is complete. Two performance gaps remain accepted for now:

  • mixed and real-world map building is slower than dotenv-compat because adotenv returns an ordered map and retains the lossless document;
  • the parse-only binary is about 24 percent larger than the smallest measured parser.

Both gaps are recorded in the competitive analysis and remain candidates for later optimization.

Acceptance for 1.0

  • The comparison covers simple, mixed and real-world fixtures.
  • All compared parsers use the same input and output definition.
  • Wall time, CPU time, instructions, RSS and binary size are recorded.
  • Results are reproducible with one command on kbench.
  • The docs site publishes the current results and method.
  • Performance budgets pass.
  • Any remaining gap is documented with a reason.

Competitive analysis

This document compares adotenv with common Rust .env parsers and loaders. The goal is to identify concrete work, not to rank libraries on a single number.

Comparison set

  • adotenv 0.10.4
  • dotenvy 0.15.7
  • dotenv 0.15.0
  • dotenv-compat 0.4.0
  • dotenv-ng-core 1.0.0
  • dotenv-parser 0.1.3
  • dotenv-linter 4.0.0 for CLI comparison

Method

Benchmarks use a release build on Linux x86_64. The fixture has 65,536 simple KEY=value entries and a 1.4 MB source. Each library reads the source before measurement, parses it, and builds a map where the library supports one.

  • Reference host: kbench, Intel Core i7-6700HQ, 4 cores and 8 threads, 2.6 GHz, Linux x86_64, rustc 1.97.0. The benchmark process was pinned to one CPU.
  • Wall time: hyperfine, ten process runs, multiple parses per run, using the median process time. CPU time is recorded separately.
  • Instructions: Valgrind callgrind, one parse, with a no-op baseline.
  • Memory: /proc/self/status high-water mark and retained RSS, with a no-op baseline.
  • Binary size: stripped release binary with thin LTO for a parse-and-print program.
  • Dependencies and MSRV: crates.io metadata.

Run python3 scripts/compare.py --update-doc to regenerate the benchmark section on an idle host. The tables below were recorded on kbench. Raw results are stored in benchmarks/compare/results.json.

Results

Generated by python3 scripts/compare.py on kbench with rustc 1.97.0 (2d8144b78 2026-07-07).

Parse and map build

Per parse on the 65,536-entry fixture:

ImplementationWall timeCPU timeInstructionsPeak RSSRetained RSS
adotenv parse34.9 ms34.8 ms207M26.5 MB26.5 MB
adotenv parse + values54.4 ms54.2 ms366M10.0 MB10.0 MB
adotenv parse + map42.4 ms42.3 ms277M32.6 MB14.2 MB
dotenvy iterator + map68.7 ms68.5 ms361M18.7 MB11.2 MB
dotenv-compat parse50.0 ms49.8 ms272M19.9 MB14.5 MB
dotenv-ng input-only93.7 ms93.4 ms384M26.2 MB20.2 MB
dotenv-parser map162.2 ms161.9 ms1242M27.2 MB10.6 MB

Small-file parsing, in microseconds per parse:

Implementation128 entries
adotenv parse52.2 us
adotenv parse + values76.8 us
adotenv parse + map70.8 us
dotenvy iterator + map106.9 us
dotenv-compat parse78.1 us
dotenv-ng input-only105.7 us
dotenv-parser map269.5 us

Mixed fixture

Per parse on a 4,096-block mixed fixture with quoting, spacing, assigned values, Unicode and comments:

ImplementationWall timeCPU timeInstructionsPeak RSSRetained RSS
adotenv parse20.6 ms20.5 ms106M19.5 MB11.0 MB
adotenv parse + values22.7 ms22.6 ms139M3.8 MB3.8 MB
adotenv parse + map27.6 ms27.5 ms153M19.2 MB5.8 MB
dotenvy iterator + map25.8 ms25.8 ms158M5.6 MB4.2 MB
dotenv-compat parse17.7 ms17.6 ms108M7.4 MB4.8 MB
dotenv-ng input-only24.5 ms24.4 ms137M7.6 MB6.1 MB
dotenv-parser map63.7 ms63.6 ms471M11.7 MB4.1 MB

Loader profiles

Per load on the 65,536-entry fixture:

ProfileWall timeCPU timeInstructionsPeak RSSRetained RSS
values-only load54.9 ms54.8 ms366M11.2 MB9.9 MB
default load73.9 ms73.7 ms465M47.7 MB27.9 MB
full-report load120.1 ms119.7 ms687M57.5 MB37.7 MB

Real-world corpus

Per parse on the pinned public corpus, repeated with unique key prefixes:

ImplementationWall timeCPU timeInstructionsPeak RSSRetained RSS
adotenv parse9.4 ms9.4 ms63M7.7 MB4.2 MB
adotenv parse + values9.5 ms9.5 ms66M1.2 MB1.2 MB
adotenv parse + map11.2 ms11.2 ms78M8.3 MB2.4 MB
dotenvy iterator + map11.4 ms11.3 ms72M2.7 MB1.7 MB
dotenv-compat parse7.8 ms7.8 ms56M4.0 MB2.0 MB
dotenv-ng input-only10.9 ms10.9 ms72M3.6 MB2.8 MB
dotenv-parser map36.1 ms36.0 ms277M6.1 MB1.4 MB

Dialect matrix

Per parse on the 65,536-entry simple fixture:

DialectWall timeCPU timeInstructionsPeak RSSRetained RSS
Node60.6 ms60.4 ms423M32.8 MB14.3 MB
Python50.6 ms50.4 ms329M32.6 MB14.3 MB
Compose51.7 ms51.5 ms305M32.5 MB14.2 MB
Docker42.7 ms42.5 ms269M32.6 MB14.3 MB

Parse-only binaries

BinaryStripped size
adotenv454 KB
dotenv_compat367 KB
dotenv_ng404 KB
dotenv_parser450 KB
dotenvy386 KB

Wall time varies with host load. Instruction counts are the stable CPU metric. adotenv parse uses fewer instructions than every measured alternative. Its parse-plus-map path uses more instructions than dotenv-compat and fewer than dotenvy. adotenv also retains more document memory because it keeps spans, items and diagnostics. The values-only projection path is the correct comparison for applications that do not need provenance.

Footprint

CrateRequired normal dependenciesMSRV
adotenv01.81
dotenvy01.56
dotenv0not declared
dotenv-compat41.85
dotenv-ng-core01.74
dotenv-parser2not declared

adotenv has no required runtime dependencies and supports no_std with alloc. Its parse-only binary is within about 22 percent of the smallest measured binary.

Capability matrix

Capabilityadotenvdotenvydotenvdotenv-compatdotenv-ngdotenv-parser
Literal parseYesYesYesYesYesYes
Map or iterator APIBothIteratorLoaderMapMapMap
Lossless sourceYesNoNoNoNoNo
Token spans and positionsYesNoNoNoNoNo
Recovery diagnosticsYesLimitedNoLimitedErrorsErrors
Multiple syntax presets511Node11
Verified edits and formattingYesNoNoNoNoNo
Sort, compare and examplesYesNoNoNoNoNo
Reference inspectionYesNoNoNoNoNo
Explicit resolutionYesNoNoNoSubstitutionNo
Cascade loading and provenanceYesSearch/loadSearch/loadFilesMultiple filesNo
Values-only projectionYesNoNoNoNoNo
SerdeYesNoNoNoNoNo
CLIYesNoNoNoNoNo
no_stdYesNoNoNoNoNo

dotenv-linter is a separate CLI category. It provides lint rules and fixes but does not expose adotenv’s library model or loader provenance.

Correctness

adotenv has a pinned conformance corpus for Node, Python, Compose and Docker behavior. It also has parser, edit, encoding, resolution and formatting fuzz targets.

The main gap is breadth, not absence. Add more pinned symbols and differential checks against Node and Python, especially for malformed and multiline cases. Keep every observation tied to a source identity and input hash.

Documentation

adotenv has 20 public documents covering architecture, dialects, edits, formatting, resolution, loading, serde, CLI, integrations, quality and performance. Examples in the README are doctested.

Gaps:

  • no dedicated documentation site;
  • no guided tutorial or task-based examples;
  • design examples are not compiled;
  • no searchable API-to-task index;
  • no published benchmark dashboard;
  • adotenv.github.io is unavailable because there is no adotenv GitHub organization.

A project site can use asamarts.github.io/adotenv now, or a custom domain, or an adotenv organization if that name can be created.

API assessment

Strengths:

  • one parse result supports inspection, edits, comparison and formatting;
  • policy is explicit and composable;
  • errors and diagnostics are structured;
  • loading and process mutation stay opt-in;
  • values, origins and reports are borrowed views.

Risks:

  • the public surface is broad for new users;
  • similar concepts use several types (Document, Entry, Item, views, LoadedEnvironment, ResolvedEnvironment);
  • examples are spread across the README and design docs;
  • the migration from parse to edit to load is not presented as a single guided path.

The next step is a task-based API audit, not a redesign.

Priorities

  1. Commit a reproducible comparison harness and keep these numbers current.
  2. Build a documentation site with a tutorial, task guides and search.
  3. Improve values-only performance and memory measurements against the same fixture.
  4. Expand conformance and differential tests.
  5. Audit common-task APIs and consolidate examples.
  6. Close feature gaps only where they fit the literal, explicit design. File encryption, implicit environment search and ambient command execution are not automatic goals.
  7. Complete the 1.0 evidence for fuzzing and external adoption.

Benchmark dashboard

Reference host: kbench, Intel Core i7-6700HQ, 4 cores and 8 threads, 2.6 GHz, 15 GiB RAM, rustc 1.97.0. Tests were pinned to one CPU.

Method and budgets are in performance.md and benchmark-plan.md. Full comparison tables are in competitive-analysis.md.

Raw data:

Simple fixture

65,536 declarations:

ImplementationWallInstructionsRetained
adotenv parse33.3 ms207M26.5 MB
adotenv parse + map40.9 ms277M14.2 MB
dotenv-compat50.3 ms271M14.5 MB
dotenvy + map69.5 ms361M11.4 MB
dotenv-ng94.5 ms384M20.3 MB
dotenv-parser163.9 ms1,243M10.6 MB

Mixed fixture

ImplementationWallInstructionsRetained
adotenv parse20.6 ms106M11.0 MB
adotenv parse + map27.6 ms153M5.8 MB
dotenv-compat17.7 ms108M4.8 MB

Real-world corpus

Pinned public configurations, repeated with unique key prefixes.

ImplementationWallInstructionsRetained
adotenv parse9.4 ms63M4.2 MB
adotenv parse + map11.2 ms78M2.4 MB
dotenv-compat7.8 ms56M2.0 MB

Loader profiles

ProfileWallInstructionsRetained
Values only54.9 ms366M9.9 MB
Default73.9 ms465M27.9 MB
Full reports120.1 ms687M37.7 MB

Dialects

Adotenv only, 65,536 declarations:

DialectWallInstructionsRetained
Portable33.3 ms207M26.5 MB
Node60.6 ms423M14.3 MB
Python50.6 ms329M14.3 MB
Compose51.7 ms305M14.2 MB
Docker42.7 ms269M14.3 MB

Adotenv-only operations

65,536-entry workloads:

OperationTime
native set61.7 ms
native batch63.6 ms
format normalize76.7 ms
sort161.1 ms
compare large versus typical61.0 ms

Build and startup

Clean release builds on kbench:

BinaryBuildStripped sizeStartup
adotenv46.6 s454 KB651 us
dotenv-compat45.0 s367 KB664 us
dotenvy45.2 s386 KB660 us
dotenv-ng45.1 s404 KB661 us
dotenv-parser45.7 s450 KB698 us
adotenv-cli16.8 s1,199 KB653 us

Feature-set library sizes:

FeaturesBuildLibrary
alloc11.9 s2,616 KB
std11.9 s2,616 KB
load13.2 s3,111 KB
serde14.8 s2,789 KB
load + serde16.2 s3,324 KB
exec12.1 s2,654 KB

Current conclusions

  • adotenv is fastest on simple parse and map building.
  • dotenv-compat is faster on mixed and real-world map building because it returns an unordered map and does not retain the lossless document model.
  • adotenv uses the lowest instructions for simple parsing and keeps default loading below 30 MB retained.
  • The parse-only binary is about 24 percent larger than the smallest measured parser.

Feature decisions

Feature decisions use these rules:

  • Keep parsing literal and free of implicit I/O.
  • Keep the core free of required runtime dependencies.
  • Preserve the MSRV and no_std build.
  • Prefer a separate crate when a feature needs a protocol, crypto, editor integration or opinionated policy.
  • Adopt compatibility behavior only when a pinned upstream oracle exists.

Decisions

AreaDecisionReason
Values-only parsingAdoptAdded parse_values and parse_values_with; no document metadata is retained.
Linter rules and fixesIncubate as a companion cratedotenv-linter has 14 opinionated checks. Lint policy does not belong in the parser core.
LSP and editor supportIncubate as a companion crateadotenv already has spans, diagnostics and edits. An LSP needs editor protocol work, not parser changes.
Encryption and vault filesReject for the coreCrypto, key handling and secret distribution are a different product. Keep adotenv usable as a literal parser.
Implicit file searchRejectLoading is explicit by directory, mode or ordered file list. Hidden search changes behavior by working directory.
Environment inheritanceRejectBare keys stay bare. Callers provide lookup values explicitly.
Ambient command executionRejectCommands stay literal unless a caller supplies a callback or uses the opt-in exec helper.
Process mutationKeep in adotenv-processThe core stays safe. Process mutation remains isolated and unsafe by design.
CLI process executionKeep in adotenv-cli runThe CLI starts a child without mutating the parent environment.
More syntax presetsConditionalAdd a preset only with a pinned source, package identity and reproducible observations.
Streaming parserDeferparse_values covers the low-memory map path. Streaming needs a separate grammar and error model decision.
More adaptersConditionalAdd an adapter when it can consume documents or loaded environments without changing core policy.

Linter companion

dotenv-linter demonstrates demand for checks such as duplicate keys, unordered keys, lowercase keys, quote style, whitespace, leading characters and schema rules. adotenv should not add those policies to the parser.

A future adotenv-lint crate can use the existing document model and return structured findings and verified edits. It should not require changes to core parsing.

LSP companion

Editor demand is visible in ecolog-lsp, which provides completion, definition, hover, references, rename, diagnostics and value masking across many languages.

A future adotenv-lsp crate can reuse Document spans and VerifiedEdits. It should keep value masking opt-in and avoid ambient environment lookup by default.

Syntax presets

New presets must include:

  • a pinned implementation version and source identity;
  • a reproducible oracle adapter;
  • input and profile hashes;
  • a clear statement of which behavior is literal and which requires loading or interpolation support.

Presets without a stable oracle remain rejected.

Streaming

A streaming API would need to preserve source spans and recovery behavior, or explicitly define a reduced stream mode. This is deferred until a real user needs bounded-memory streaming beyond parse_values and the values-only loaders.

1.0 readiness

A 1.0 release requires all of the following.

Current release: 0.10.4.

Criteria

ItemStatus
Workspace quality gateComplete: 341 tests, no_std, MSRV, docs, package and semver
Conformance corpusComplete for the current corpus: 80 cases and 20 profiles
Manual performance recordComplete on kbench
Sustained fuzzingIn progress: 1 of 4 scheduled runs
External adopterComplete: alint, a separate public repository
CLI and library contractsFrozen

Required evidence

  1. Re-run the release gate from quality.md.
  2. Record an idle-host performance run and compare it with performance.md and benchmark-dashboard.md.
  3. Confirm four consecutive scheduled fuzz runs for all five targets after the last parser, edit, encoding, resolution or formatting change. The first run passed on 2026-10-26; the next dates are 2026-11-02, 2026-11-09 and 2026-11-16.
  4. Confirm at least one external project uses a published adotenv crate, with the maintainer’s confirmation and the version and features used.
  5. Verify the tag, crates.io versions, docs.rs builds and GitHub Release.

External adopter

  • Project: asamarts/alint, a separate public repository.
  • Adotenv version: 0.10.4, default features.
  • Use: literal dotenv parsing, diagnostics, spans and native verified edits in alint-core.
  • Compatibility finding: upgrading from adotenv 0.2.1 required no alint source changes.
  • Verification: 887 alint-core tests and 270 alint CLI tests pass; Cross-Platform CI and the quality jobs pass.

Fuzz rule

A manual fuzz run does not count toward the four scheduled runs. A skipped, cancelled or failed run does not count. A fuzz-relevant code change restarts the count.

Adopter rule

An adopter must be outside this repository and must use a published crate. Downloads, stars and issue comments do not qualify. Public attribution is optional; the maintainer confirmation must be recorded.