Documentation
¶
Overview ¶
Package dotenv parses `.env` files into a lossless AST, a key/value map, or a *Source suitable for an environment-variable loader.
The package targets a conservative portable subset of the `.env` dialects in the wild. Every dialect-specific extension (the `export ` prefix, YAML-style `KEY: value`, backtick quoting, variable expansion, multi-line double-quoted strings) is supported but gated behind explicit ParseOptions or, for non-portable constructs that nonetheless parse, surfaced as LintIssue SeverityWarning entries from Lint.
API summary ¶
- Parse / Load — bytes / file → AST or key/value map.
- NewSource / NewReaderSource / MapSource — file / reader / map → *Source.
- Lint — flag non-portable constructs.
- File.Get/Set/Delete/Keys/ToMap — AST navigation and mutation.
- File.Source — snapshot the AST's effective map as a *Source.
- File.Marshal — byte-exact round-trip emission of an AST.
- Marshal / Write — map → `.env` bytes / file.
- IsValidKey — predicate for keys File.Set / Marshal accept.
Dependencies ¶
dotenv has no dependencies — not the standard library only in spirit, but literally: nothing outside the standard library, and no other go-rotini module either. Its Source is a struct whose single Lookup method is structurally compatible with the Source interface in github.com/go-rotini/env, so a parsed `.env` file drops straight into an env loader's source chain without dotenv importing env. Likewise WithExpansionParent takes the small VarLookup interface, which any env.Source (e.g. env.OSEnv()) satisfies; for the common case there is also WithExpandFromOSEnv, which uses only os.LookupEnv.
File watching ¶
This package does not watch files. It reads the source once at construction (NewSource / NewReaderSource / Load); subsequent on-disk changes do not propagate. Live reload of a `.env` file is the job of a higher-level package that composes github.com/go-rotini/fs's file watcher with Parse — the same arrangement used for yaml/toml/jsonc/jsonschema config files — so the watching dependency lives in exactly one place.
Example ¶
Parse a `.env` byte stream and read values out of the resulting AST.
package main
import (
"fmt"
"log"
"github.com/go-rotini/dotenv"
)
func main() {
f, err := dotenv.Parse([]byte("# app config\nAPP_NAME=demo\nPORT=8080\n"))
if err != nil {
log.Fatal(err)
}
for _, k := range []string{"APP_NAME", "PORT"} {
v, _ := f.Get(k)
fmt.Printf("%s=%s\n", k, v)
}
}
Output: APP_NAME=demo PORT=8080
Index ¶
- Variables
- func IsValidKey(key string) bool
- func Load(path string, opts ...ParseOption) (map[string]string, error)
- func Marshal(m map[string]string) ([]byte, error)
- func Write(path string, m map[string]string) error
- type Entry
- type EntryKind
- type ExpansionError
- type File
- func (f *File) Delete(key string) bool
- func (f *File) Entries() []Entry
- func (f *File) Get(key string) (value string, ok bool)
- func (f *File) Keys() []string
- func (f *File) Marshal() ([]byte, error)
- func (f *File) Path() string
- func (f *File) Set(key, value string)
- func (f *File) Source() *Source
- func (f *File) ToMap() map[string]string
- type LintIssue
- type ParseError
- type ParseOption
- func WithBackticks() ParseOption
- func WithExpand() ParseOption
- func WithExpandFromOSEnv() ParseOption
- func WithExpansionParent(parent VarLookup) ParseOption
- func WithMaxExpansionDepth(n int) ParseOption
- func WithMaxFileSize(n int64) ParseOption
- func WithMaxLineLength(n int) ParseOption
- func WithRelaxedNames() ParseOption
- func WithStrictDotenv() ParseOption
- func WithStrictExpansion() ParseOption
- type Position
- type Severity
- type Source
- type VarLookup
Examples ¶
Constants ¶
This section is empty.
Variables ¶
var ( // ErrParse matches any [*ParseError]. ErrParse = &ParseError{} // ErrExpansion matches any [*ExpansionError]. ErrExpansion = &ExpansionError{} // ErrFileTooLarge is returned when an input exceeds [WithMaxFileSize]. ErrFileTooLarge = errors.New("dotenv: file size exceeds limit") // ErrLineTooLong is returned when a single line exceeds [WithMaxLineLength]. ErrLineTooLong = errors.New("dotenv: line length exceeds limit") // ErrUnsetVariable is the [ExpansionError.Cause] when expansion under // [WithStrictExpansion] references a variable that is not set. ErrUnsetVariable = errors.New("dotenv: variable referenced in expansion is unset") // ErrAssertionFailed is the [ExpansionError.Cause] when a `${VAR:?msg}` // assertion fails during expansion. ErrAssertionFailed = errors.New("dotenv: ${VAR:?...} assertion failed") // ErrMaxExpansionDepth is the [ExpansionError.Cause] when expansion // recursion exceeds the [WithMaxExpansionDepth] bound. ErrMaxExpansionDepth = errors.New("dotenv: variable-expansion depth limit exceeded") // ErrInvalidKey reports a key that is not a valid `.env` identifier (see // [IsValidKey]). [Marshal] / [Write] return an error wrapping it; [File.Set] // panics with an error wrapping it. ErrInvalidKey = errors.New("dotenv: invalid key") )
Sentinel errors. The struct-pointer sentinels match by type via the corresponding Is method; the others match by value identity.
Functions ¶
func IsValidKey ¶
IsValidKey reports whether key is a syntactically valid `.env` key — one that round-trips through Parse when WithRelaxedNames is in effect. A valid key is non-empty, does not begin with a digit, and otherwise contains only identifier bytes: ASCII letters, digits and '_', plus — beyond the portable POSIX subset [A-Za-z_][A-Za-z0-9_]* — '-', '.', and non-ASCII bytes. (Keys outside the POSIX subset only re-parse under WithRelaxedNames; the POSIX subset is always safe.)
Marshal / Write reject an invalid key with an error wrapping ErrInvalidKey; File.Set panics with one.
func Load ¶
func Load(path string, opts ...ParseOption) (map[string]string, error)
Load reads a `.env` file from path and returns its key/value map (last-write-wins per Bash convention).
func Marshal ¶
Marshal renders m as a `.env`-format byte stream: one `KEY=VALUE` line per entry, keys in sorted (deterministic) order, LF line endings, values double-quoted only when a bare value would not round-trip. The output re-parses to an equivalent map via Parse / Load.
Marshal returns an error wrapping ErrInvalidKey if any key is not a valid `.env` key (see IsValidKey) — emitting such a key would produce output that fails to re-parse. To turn a parsed *File back into bytes (preserving comments and formatting), use [(*File).Marshal] instead.
Example ¶
Marshal renders a map as `.env` bytes (sorted keys, LF endings, minimal quoting). The result re-parses via Parse / Load.
package main
import (
"fmt"
"log"
"github.com/go-rotini/dotenv"
)
func main() {
out, err := dotenv.Marshal(map[string]string{
"PORT": "8080",
"DATABASE_URL": "postgres://localhost/app",
"FEATURE_FLAGS": "a,b,c",
"GREETING": "hello world", // forces quoting
})
if err != nil {
log.Fatal(err)
}
fmt.Print(string(out))
}
Output: DATABASE_URL=postgres://localhost/app FEATURE_FLAGS=a,b,c GREETING="hello world" PORT=8080
func Write ¶
Write renders m with Marshal and writes it to path with 0o600 permissions (creating the file, or truncating an existing one without changing its mode). Write is not atomic; for crash-safe replacement, use Marshal and write the bytes through your preferred mechanism (for example, github.com/go-rotini/fs's atomic WriteFile).
Example ¶
Write renders a map with Marshal and writes it to a file (0o600).
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"github.com/go-rotini/dotenv"
)
func main() {
dir, err := os.MkdirTemp("", "dotenv-example")
if err != nil {
log.Fatal(err)
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, ".env")
if err := dotenv.Write(path, map[string]string{"FOO": "bar", "BAZ": "qux"}); err != nil {
log.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil {
log.Fatal(err)
}
fmt.Print(string(data))
}
Output: BAZ=qux FOO=bar
Types ¶
type Entry ¶
type Entry struct {
Kind EntryKind
// Key is the assignment key (without the `export ` prefix), empty
// for non-assignment entries.
Key string
// Value is the decoded value: escapes interpreted, quoting
// stripped, and (with [WithExpand]) variable references resolved.
// Empty for non-assignment entries.
Value string
// Pos is the source position of the entry's first byte.
Pos Position
// contains filtered or unexported fields
}
Entry is one logical line in a parsed `.env` file. Most callers use File.Get / File.Set / File.Delete / File.Keys instead of reading the entry list directly.
type EntryKind ¶
type EntryKind int
EntryKind classifies one logical line in a `.env` file.
const ( // EntryBlank is a whitespace-only line. EntryBlank EntryKind = iota // EntryComment is a line whose first non-whitespace character is // `#`. Trailing comments on assignment lines are stored on the // assignment, not as separate EntryComment entries. EntryComment // EntryAssignment is a `KEY=VALUE` (or `KEY: VALUE`) line. EntryAssignment )
type ExpansionError ¶
ExpansionError is returned when variable expansion fails: an unset variable under WithStrictExpansion, a `${VAR:?msg}` assertion failure, or the WithMaxExpansionDepth bound being exceeded by a deeply nested or self-referential `${VAR:-default}` / `${VAR:?msg}` construct. ExpansionError.Cause (and hence errors.Is) is ErrUnsetVariable, ErrAssertionFailed, or ErrMaxExpansionDepth respectively; every ExpansionError also matches ErrExpansion.
func (*ExpansionError) Error ¶
func (e *ExpansionError) Error() string
func (*ExpansionError) Is ¶
func (*ExpansionError) Is(target error) bool
func (*ExpansionError) Unwrap ¶
func (e *ExpansionError) Unwrap() error
type File ¶
type File struct {
// contains filtered or unexported fields
}
File is the AST for a parsed `.env` source. Entry order and per- entry source bytes are preserved so File.Marshal is byte-exact on unmutated inputs.
A File returned by Parse / Load / NewSource is safe for concurrent reads (File.Get, File.Keys, File.ToMap, File.Entries, File.Marshal), but the mutating methods (File.Set, File.Delete) are not — guard a File you intend to mutate from concurrent goroutines.
func Parse ¶
func Parse(data []byte, opts ...ParseOption) (*File, error)
Parse parses data as a `.env` byte stream and returns the resulting AST. Use File.Get / File.Keys / File.ToMap for access, or File.Marshal for round-trip emission.
func (*File) Delete ¶
Delete removes every assignment for key. Returns true when at least one entry was removed. Comments and blank lines are unaffected.
func (*File) Entries ¶
Entries returns the entry list. The slice is not for caller mutation; use File.Set / File.Delete.
func (*File) Get ¶
Get returns the value associated with key. When the key appears in multiple assignments the LAST occurrence wins (matching `source <file>` in Bash).
func (*File) Keys ¶
Keys returns assignment keys in insertion order, deduplicated to the first occurrence.
func (*File) Marshal ¶
Marshal returns the file's source representation. For unmutated inputs the result is byte-exact with the original; entries mutated or added via File.Set are freshly rendered with default quoting while surrounding entries retain their original bytes.
Variable expansion performed at parse time (via WithExpand / WithExpandFromOSEnv) is NOT reflected here — Marshal emits the original source text; only File.Set re-renders a line. Use File.ToMap / File.Get for the expanded values.
func (*File) Path ¶
Path returns the file path the AST was loaded from, or "" for an in-memory Parse.
func (*File) Set ¶
Set assigns value to key. If key already appears, its first assignment is updated in place (re-rendered with sensible-default quoting, keeping that line's original line ending) and any later assignments of the same key are removed — so the file holds a single authoritative assignment and File.Get / File.ToMap agree with the value just set. If key is absent, a new assignment is appended on its own line, matching the line ending the source's last line uses. Surrounding entries keep their original bytes, so round-trip is byte-exact except for the mutated/added line.
Set panics (with an error wrapping ErrInvalidKey) if key is not a valid `.env` key — see IsValidKey.
Example ¶
Edit a `.env` file losslessly: Set updates an existing assignment in place (or appends a new one), Delete removes one, and Marshal emits the result — comments, quoting and untouched lines are preserved byte-for-byte.
package main
import (
"fmt"
"log"
"github.com/go-rotini/dotenv"
)
func main() {
f, err := dotenv.Parse([]byte("# generated config\nDB_HOST=localhost\nDB_PORT=5432\nDEBUG=true\n"))
if err != nil {
log.Fatal(err)
}
f.Set("DB_PORT", "5433") // update in place
f.Set("DB_NAME", "app") // append
f.Delete("DEBUG")
out, _ := f.Marshal()
fmt.Print(string(out))
}
Output: # generated config DB_HOST=localhost DB_PORT=5433 DB_NAME=app
func (*File) Source ¶
Source returns a *Source over the file's effective key/value mapping (File.ToMap) — a snapshot independent of the AST, so later File.Set / File.Delete calls do not change it. Use this when you parsed for editing but also want a value to hand to an environment-variable loader without re-parsing.
type LintIssue ¶
LintIssue describes a deviation from the portable `.env` subset: `export ` prefix, YAML-style `KEY: value`, non-POSIX identifiers (hyphens, dots), or backtick-quoted values.
func Lint ¶
func Lint(data []byte, opts ...ParseOption) ([]LintIssue, error)
Lint reports constructs in data that fall outside the portable `.env` subset. The returned slice may contain warnings even when err is non-nil — warnings encountered before a parse failure are preserved.
Warnings (SeverityWarning entries) come from a fast surface scan, not from the real parser, so on malformed input they can be approximate or diverge from what Parse reports; the returned error is authoritative for "does this parse". When parsing fails, the failure is reported both as a SeverityError entry (carrying the failure Position) and as the returned error.
Example ¶
Lint flags constructs outside the portable `.env` subset (warnings) and any parse failure (an error). The input below parses, but uses two non-portable forms.
package main
import (
"fmt"
"github.com/go-rotini/dotenv"
)
func main() {
data := []byte("export DATABASE_URL=postgres://localhost/app\nPORT: 8080\n")
issues, err := dotenv.Lint(data)
fmt.Println("err:", err)
for _, iss := range issues {
fmt.Printf("line %d: %s %s\n", iss.Pos.Line, iss.Severity, iss.Rule)
}
}
Output: err: <nil> line 1: warning export-prefix line 2: warning yaml-separator
type ParseError ¶
ParseError is returned by Parse / Load / NewSource when the `.env` source is malformed. It carries the file path (when available) and the line/column position of the failure.
func (*ParseError) Error ¶
func (e *ParseError) Error() string
func (*ParseError) Is ¶
func (*ParseError) Is(target error) bool
type ParseOption ¶
type ParseOption func(*parseOptions)
ParseOption configures Parse, Load, NewSource, NewReaderSource, and Lint.
func WithBackticks ¶
func WithBackticks() ParseOption
WithBackticks accepts backtick-quoted values (motdotla/dotenv dialect). Treated like single-quoted: literal, no escapes, no expansion. Default is to reject as a syntax error.
func WithExpand ¶
func WithExpand() ParseOption
WithExpand enables Bash-style variable expansion in unquoted and double-quoted values: `$VAR`, `${VAR}`, `${VAR:-default}`, `${VAR-default}`, `${VAR:?error}`. Backslash-dollar (`\$`) suppresses expansion. Lookups walk the file's already-parsed entries first, then any parent set via WithExpansionParent.
Example ¶
WithExpand turns on Bash-style variable expansion. References resolve against the file's already-parsed entries first; `${VAR:-default}` supplies a fallback; nested references are expanded recursively.
package main
import (
"fmt"
"log"
"github.com/go-rotini/dotenv"
)
func main() {
src := "DOMAIN=example.com\n" +
"API_URL=https://api.${DOMAIN}\n" +
"ADMIN_EMAIL=${ADMIN_EMAIL:-admin@${DOMAIN}}\n"
f, err := dotenv.Parse([]byte(src), dotenv.WithExpand())
if err != nil {
log.Fatal(err)
}
for _, k := range []string{"API_URL", "ADMIN_EMAIL"} {
v, _ := f.Get(k)
fmt.Println(k, "=", v)
}
}
Output: API_URL = https://api.example.com ADMIN_EMAIL = admin@example.com
func WithExpandFromOSEnv ¶
func WithExpandFromOSEnv() ParseOption
WithExpandFromOSEnv enables variable expansion (like WithExpand) and resolves references not defined in the file against the process environment via os.LookupEnv. It is equivalent to `WithExpand()` plus `WithExpansionParent(<an os.LookupEnv-backed VarLookup>)`, using only the standard library.
func WithExpansionParent ¶
func WithExpansionParent(parent VarLookup) ParseOption
WithExpansionParent sets the VarLookup consulted by variable expansion when a referenced variable is not present in the file's own entries. It has no effect unless WithExpand (or WithExpandFromOSEnv) is also set.
Any github.com/go-rotini/env Source satisfies VarLookup, so passing env.OSEnv() or env.Map(...) works directly. For the common "fall back to the process environment" case, WithExpandFromOSEnv is shorter and pulls in no dependency.
func WithMaxExpansionDepth ¶
func WithMaxExpansionDepth(n int) ParseOption
WithMaxExpansionDepth bounds variable-expansion recursion. Default 16; a non-positive value keeps the default. The bound is reached by deeply nested or self-referential `${VAR:-default}` / `${VAR:?msg}` constructs (e.g. `${A:-${A:-${A}}}`); exceeding it yields an *ExpansionError whose cause is ErrMaxExpansionDepth. Plain references like `${A}` (and even `A=${A}`) are resolved at most once against already-resolved entries and never recurse.
func WithMaxFileSize ¶
func WithMaxFileSize(n int64) ParseOption
WithMaxFileSize caps the total input size. Default 10 MiB. Zero or negative means unlimited.
func WithMaxLineLength ¶
func WithMaxLineLength(n int) ParseOption
WithMaxLineLength caps a single physical line's length. Default 1 MiB. Zero or negative means unlimited. Multi-line double-quoted values count toward the line limit per physical line, not in aggregate.
func WithRelaxedNames ¶
func WithRelaxedNames() ParseOption
WithRelaxedNames accepts identifiers containing characters outside the POSIX [A-Za-z_][A-Za-z0-9_]* class (hyphens, dots, non-ASCII).
Interaction with WithExpand: a `${...}` reference is always parsed with the Bash `${VAR-default}` / `${VAR:-default}` rules, so a hyphen inside the braces is treated as the default-value operator, not as part of the name — `${MY-KEY}` means "value of MY, or the literal `KEY` if MY is unset", even if a key literally named `MY-KEY` exists. A hyphenated key therefore cannot be interpolated via `${...}`; reference it through a parent (WithExpansionParent) keyed on the hyphenated name, or avoid hyphens in keys you interpolate. (`$NAME` without braces only ever consumes POSIX identifier bytes, so `$MY-KEY` is `<value of MY>` followed by the literal `-KEY` regardless of this option.) Dots in names do not collide with any expansion operator.
func WithStrictDotenv ¶
func WithStrictDotenv() ParseOption
WithStrictDotenv rejects non-portable constructs: YAML-style `KEY: value` separators, the `export ` prefix, identifiers outside the POSIX class. Useful for asserting cross-dialect portability.
func WithStrictExpansion ¶
func WithStrictExpansion() ParseOption
WithStrictExpansion makes a reference to an unset variable raise an *ExpansionError (cause ErrUnsetVariable) rather than expand to empty. Has no effect unless WithExpand (or WithExpandFromOSEnv) is also set.
type Position ¶
Position identifies a location in a `.env` source. File is the path the source was loaded from, or "" for an in-memory Parse. Line and Column are 1-based; Offset is the 0-based byte offset of the location within the source. The zero value (all fields zero) means "no position information".
Position is the location type reported by ParseError, ExpansionError, LintIssue, and Entry.
type Source ¶
type Source struct {
// contains filtered or unexported fields
}
Source is an in-memory key/value view of a parsed `.env` file, returned by NewSource / NewReaderSource / MapSource / [(*File).Source]. It is a snapshot taken at construction; later changes to the underlying file do not propagate (for live reload, layer a file watcher — e.g. from github.com/go-rotini/fs — over Parse and re-derive the source on change).
(*Source).Lookup makes *Source structurally compatible with the Source interface in github.com/go-rotini/env, so a parsed `.env` file drops straight into an env loader's source chain without dotenv depending on env.
func MapSource ¶
MapSource returns a *Source over a copy of m. It is handy for supplying an in-memory parent to WithExpansionParent, or in tests. The returned Source is independent of m — later changes to m are not reflected.
func NewReaderSource ¶
func NewReaderSource(r io.Reader, opts ...ParseOption) (*Source, error)
NewReaderSource reads a `.env` byte stream from r and returns a *Source over its parsed map.
Example ¶
A *Source's Lookup method makes a parsed file usable wherever a github.com/go-rotini/env Source is expected — no import edge needed. (set==true with an empty value means "present but empty".)
package main
import (
"fmt"
"log"
"strings"
"github.com/go-rotini/dotenv"
)
func main() {
src, err := dotenv.NewReaderSource(strings.NewReader("FOO=bar\nDEBUG=\n"))
if err != nil {
log.Fatal(err)
}
foo, ok := src.Lookup("FOO")
fmt.Printf("FOO=%q (set=%t)\n", foo, ok)
dbg, ok := src.Lookup("DEBUG")
fmt.Printf("DEBUG=%q (set=%t)\n", dbg, ok)
_, ok = src.Lookup("MISSING")
fmt.Printf("MISSING set=%t\n", ok)
}
Output: FOO="bar" (set=true) DEBUG="" (set=true) MISSING set=false
func NewSource ¶
func NewSource(path string, opts ...ParseOption) (*Source, error)
NewSource reads a `.env` file from path and returns a *Source over its parsed map. The source is a snapshot taken at construction; subsequent file changes do not propagate (for live reload, layer a file watcher — e.g. from github.com/go-rotini/fs — over Parse and re-derive the source on change).
(*Source).Lookup makes the result usable wherever a github.com/go-rotini/env Source is expected, without dotenv importing env.
type VarLookup ¶
type VarLookup interface {
// Lookup returns the value associated with key and whether the key is
// set. ok=true with an empty value means "set but empty".
Lookup(key string) (value string, ok bool)
}
VarLookup resolves a variable name to its value. It is the contract WithExpansionParent uses to resolve `$VAR` / `${VAR}` references that are not defined in the file itself.
The single method matches the Source interface in github.com/go-rotini/env, so env.OSEnv(), env.Map(...), and any other env.Source satisfy VarLookup — dotenv does not import env to make that work. (For the common "resolve against the process environment" case, prefer WithExpandFromOSEnv, which needs no argument; for an in-memory map, MapSource returns a VarLookup.)