A Go .env file parser: a conservative, portable subset of the .env dialects in
the wild, with opt-in extensions, a lossless AST for round-trip editing, a linter
that flags non-portable constructs, and a Source that drops straight into an
environment-variable loader.
This package is the .env parsing layer for go-rotini/env
and the rotini CLI framework — but it has
no dependencies: nothing outside the standard library, and no other go-rotini
module either. dotenv.Source is a small struct whose Lookup method is
structurally compatible with go-rotini/env's Source interface, so a parsed
.env file plugs into an env loader's source chain without dotenv importing env;
likewise WithExpansionParent takes a one-method VarLookup interface that any
env.Source (e.g. env.OSEnv()) satisfies.
Watching / live reload is not in this package. Re-reading a
.envfile when it changes on disk is handled one layer up, by a package that composesgo-rotini/fs's file watcher withdotenv.Parse— the same mechanism used for yaml/toml/jsonc/jsonschema config files.dotenvitself reads the file once at construction; subsequent on-disk changes do not propagate.
- Conservative portable
.envsubset by default; every dialect-specific extension (exportprefix, YAML-styleKEY: value, backtick quoting, variable expansion, multi-line double-quoted strings, relaxed identifiers) is opt-in viaParseOptions. - Bash-style variable expansion (opt-in):
$VAR,${VAR},${VAR:-default},${VAR-default},${VAR:?error},\$to suppress; resolve references not defined in the file from the process environment withWithExpandFromOSEnv(), or from anyVarLookup(e.g.env.OSEnv(),env.Map(...)) viaWithExpansionParent. - Lossless
FileAST: entry order and per-entry source bytes are preserved, so(*File).Marshalis byte-exact on unmutated input;Set/Deletere-render only the affected line. (Setpanics on a syntactically invalid key — seeIsValidKey. Variable expansion done at parse time is not reflected byMarshal; useToMap.) - Write back out:
Marshal(map[string]string) ([]byte, error)renders a map as.envbytes (sorted keys, minimal quoting);Write(path, map)does that plus a0o600write. Lintreports deviations from the portable subset (exportprefix,KEY: value, non-POSIX identifiers, backtick values) as warnings, and parse failures as errors.Sourceadapters:NewSource(path),NewReaderSource(io.Reader),MapSource(map), and(*File).Source— all return a*Sourcethat any env loader accepts as aSource.- DoS guards: max file size (default 10 MiB), max line length (default 1 MiB), and a
bounded expansion-recursion depth (default 16) that catches deeply nested /
self-referential
${VAR:-default}chains. - Zero dependencies —
go.modhas norequireline at all (only the devtoolblock).
go get github.com/go-rotini/dotenvRequires Go 1.26 or later.
import "github.com/go-rotini/dotenv"
m, err := dotenv.Load(".env", dotenv.WithExpand())
if err != nil { log.Fatal(err) }
fmt.Println(m["DATABASE_URL"])dotenv.Parse([]byte, ...ParseOption) does the same from a byte slice and returns the
*File AST.
dotenv.NewSource returns a *dotenv.Source, which satisfies go-rotini/env's
Source interface structurally (no import edge needed):
import (
"github.com/go-rotini/env"
"github.com/go-rotini/dotenv"
)
src, err := dotenv.NewSource(".env", dotenv.WithExpandFromOSEnv())
if err != nil { log.Fatal(err) }
// OSEnv() listed first wins over the .env file (the standard Compose / Heroku
// precedence model). Reverse the order to make the file authoritative.
loader := env.New(env.WithSource(env.OSEnv(), src))
var cfg Config
if err := loader.Load(&cfg); err != nil {
log.Fatal(env.FormatError(err))
}dotenv.NewReaderSource(io.Reader, ...ParseOption) builds a *Source from an already-open
stream; dotenv.MapSource(map[string]string) from an in-memory map (handy as a
WithExpansionParent). All take a snapshot at construction. *Source has Lookup,
Keys, and ToMap. If you already have a *File (from Parse) and also want a loader
source, call f.Source() — no re-parsing.
data, _ := os.ReadFile(".env")
f, err := dotenv.Parse(data)
if err != nil { log.Fatal(err) }
f.Set("FEATURE_FLAGS", "feature-a,feature-b") // re-renders only this line
f.Delete("OBSOLETE_KEY")
out, _ := f.Marshal() // byte-exact for every untouched line
os.WriteFile(".env", out, 0o600)Set panics if the key isn't a valid .env key (dotenv.IsValidKey is the predicate;
the panic value wraps dotenv.ErrInvalidKey). It updates the first assignment of the
key in place — preserving that line's original line ending — and removes any later
assignments of the same key, so Get/ToMap agree with the value just set; if the key is
absent it appends a new line (matching the source's line ending, and terminating the
previous last line first if the source lacked a trailing newline). (*File).Marshal
reflects Set/Delete mutations, not variable expansion performed at parse time (use
ToMap/Get for expanded values).
m := map[string]string{"PORT": "8080", "GREETING": "hello world"}
b, err := dotenv.Marshal(m) // []byte: sorted keys, LF endings, minimal quoting
if err != nil { log.Fatal(err) } // err (wrapping ErrInvalidKey) if a key is invalid
_ = b
if err := dotenv.Write(".env", m); err != nil { // Marshal + write with 0o600 (not atomic)
log.Fatal(err)
}issues, err := dotenv.Lint(data)
for _, iss := range issues {
fmt.Printf("%s: [%s] %s: %s\n", iss.Pos, iss.Severity, iss.Rule, iss.Message)
}
// err != nil when the input failed to parse; warnings collected before the
// failure are still returned.| Option | Effect |
|---|---|
WithExpand() |
Enable $VAR / ${VAR...} expansion in unquoted and double-quoted values. |
WithExpandFromOSEnv() |
Like WithExpand(), and resolve references not defined in the file from the process environment (os.LookupEnv). |
WithExpansionParent(VarLookup) |
Resolve references not defined in the file from a VarLookup (e.g. env.OSEnv(), env.Map(...)). Requires WithExpand() or WithExpandFromOSEnv(). |
WithStrictExpansion() |
Unset variable references raise *ExpansionError (cause ErrUnsetVariable) instead of expanding to empty. |
WithMaxExpansionDepth(int) |
Bound expansion recursion (default 16). |
WithBackticks() |
Accept backtick-quoted values (motdotla/dotenv dialect); treated like single quotes. |
WithStrictDotenv() |
Reject non-portable constructs (KEY: value, export prefix, non-POSIX identifiers). |
WithRelaxedNames() |
Accept identifiers with hyphens, dots, or non-ASCII characters. |
WithMaxFileSize(int64) |
Cap total input size (default 10 MiB; ≤0 = unlimited). |
WithMaxLineLength(int) |
Cap a single physical line (default 1 MiB; ≤0 = unlimited). |
*ParseError(errors.Is(err, dotenv.ErrParse)) — malformed.envsource; carries file path and line/column (ParseError.Pos, adotenv.Position).*ExpansionError(errors.Is(err, dotenv.ErrExpansion)) — variable-expansion failure. ItsCauseisdotenv.ErrUnsetVariablefor an unset reference under strict expansion,dotenv.ErrAssertionFailedfor a failed${VAR:?msg}assertion, ordotenv.ErrMaxExpansionDepthwhen theWithMaxExpansionDepthbound was exceeded.dotenv.ErrFileTooLarge,dotenv.ErrLineTooLong— resource-limit violations.dotenv.ErrInvalidKey— wrapped by the errorMarshal/Writereturn (and by the value(*File).Setpanics with) when a key isn't a valid.envidentifier.
Full API reference is available on pkg.go.dev.
See CONTRIBUTING.md for guidelines on how to contribute to this project.
This project follows a code of conduct to ensure a welcoming community. See CODE_OF_CONDUCT.md.
To report a vulnerability, see SECURITY.md.
This project is licensed under the MIT License. See LICENSE for details.