Skip to content

Repository files navigation

dotenv

A comment-preserving .env parser and editor.

import"github.com/ubgo/dotenv"

Why not a dotenv library

Parse-to-map libraries keep the keys and throw away everything else. Write the file back and the comments, blank lines, ordering, and quoting style are gone.

That matters because a .env is documentation as much as configuration. A tool that strips the comment explaining why a value exists has damaged the file even though every key survived.

This package edits line-wise: every byte outside the entry you touch survives verbatim. Two invariants, both pinned by tests:

parse → render with no changes ⇒ byte-identical output
Set(key, <current value>) ⇒ byte-identical output (a true no-op)

Scope

A parser and editor. Three things it deliberately does not do:

Not thisWhyDo instead
Set os.EnvironReading a .env and exporting it into the process are separate decisions. Conflating them is why godotenv.Load means something this package must notApply your own precedence over f.Map()
Merge multiple filesMerging is a precedence policy — which file wins, per key. The guarantee here is that one file maps to one set of bytes, and merged content has no single file to render back toLayer it above
Coerce values to typesA .env has no types — PORT=8443 is four characters. Which Go type that becomes depends on the field it is bound to, which is the application's decision. Typed getters also fail quietly: PORT=eighty returning a default is a production incidentCompose with a binder
Stream a file in chunksForward references, insertion, and byte-exact rendering each need the whole file. A stream could resolve backward references only, which would make the same bytes expand differently here and there — worse than not offering it. Full reasoning and measurementsParseReader closes the cost that was real

The first is enforced by a test that snapshots the environment across a full parse → expand → edit → save → reopen cycle.

Why Open, not Load.godotenv.Load sets os.Environ. This package deliberately never touches the environment — it hands back a file you read, edit, and save — so a Load here would mean the opposite of every other Load in the ecosystem. The package is named for the format, the way encoding/json and yaml are.

Reading

Just want the values? One call:

m, err:=dotenv.Read(".env") // map[string]string, references resolved

That is Open + ExpandedMap, for the common case where you only need the configuration and will never write the file back. Comments, blanks, disabled settings, and unrecognised lines are all skipped; duplicates collapse to last-wins; ${VAR} references arrive resolved.

Use the full API when you need to edit, inspect structure, or keep references intact:

f, err:=dotenv.Open(".env") // a missing file is not an erroriferr!=nil {
returnerr
}
v, ok:=f.Get("DATABASE_URL")
f.Has("DATABASE_URL")
f.Keys() // active keys, in file order, deduplicatedf.Pairs() // active pairs, in file order, duplicates includedf.Map() // effective view: last occurrence wins

No filesystem needed — the same calls handle a pipe, an embedded fixture, or a secret store:

f:=dotenv.Parse(content) // from a stringf, err:=dotenv.ParseReader(resp.Body) // from any io.Reader

Prefer ParseReader for anything large.io.ReadAll doubles its buffer as it grows, and converting the resulting []byte to a string copies everything again. ParseReader copies into a strings.Builder, whose buffer becomes the string directly, so both costs disappear:

BenchmarkParseLarge/ReadAll_then_Parse 13,040,856 B/op 93 allocs/op
BenchmarkParseLarge/ParseReader 9,845,502 B/op 67 allocs/op

That is a ~1.4 MB file — realistic once a value holds a PEM block or a base64 certificate. Re-run it with task dotenv:bench rather than trusting the numbers.

Neither call streams: preserving a file byte for byte means holding every line. ParseReader lowers the cost, it does not remove it.

Cost at scale

Measured on this machine, a file of many small entries:

FileEntriesParse timeHeap
1 MB22,54726 ms+5 MB
5 MB111,542144 ms+19 MB

Roughly 4× the file size in memory and linear in time. TestParse_ScalesLinearly guards the complexity class — it caught a quadratic regression that made the 5 MB case take 23 seconds, which no unit test noticed because they all run on a handful of lines.

Open does not guess a location; you pass the path. Resolving where a file lives is appdir's job:

p, _:=dirs.ConfigFile(".env")
f, _:=dotenv.Open(p)

Writing

created:=f.Set("API_KEY", "secret") // updates in place, or appendsf.Unset("OLD_KEY", false) // comments it out — reversiblef.Unset("OLD_KEY", true) // removes it outrighterr:=f.Save() // atomic

Placing a new key

Set appends at the end of the file. To put a new key beside related ones, anchor it on a key already there:

created, err:=f.SetAfter("DB_PORT", "DB_PASSWORD", "secret") // right after DB_PORTcreated, err:=f.SetBefore("DB_HOST", "DB_DRIVER", "postgres") // right before DB_HOST

An existing key is updated in place and never moved — relocating it would rewrite two regions of the file for a one-value change. A missing anchor returns ErrAnchorNotFound and leaves the file untouched, rather than silently appending at the end while reporting success.

Inserting by section name is designed but deferred: section boundaries are a convention rather than syntax, so it has to guess, while SetAfter cannot be wrong.

Set on a duplicated key updates the last occurrence, because that is the one a consumer actually sees.

Generating a file

Set upserts. To emit content — comments, blank lines, whole sections — build entries and place them literally:

f:=dotenv.Parse("")
f.Append(
dotenv.NewComment("------------------", "DATABASE", "------------------"),
dotenv.NewPair("DB_HOST", "localhost"),
dotenv.NewPair("DB_PORT", "5432"),
dotenv.NewBlank(),
dotenv.NewComment("------------------", "AUTH", "------------------"),
dotenv.NewPair("AUTH_SECRET", "change me"),
)
f.Path=".env.example"f.Save()
# ------------------# DATABASE# ------------------DB_HOST=localhostDB_PORT=5432# ------------------# AUTH# ------------------AUTH_SECRET="change me"

Blocks can be anchored too, and land as a unit:

err:=f.InsertAfter("DB_PORT", dotenv.NewComment("added by volt"), dotenv.NewPair("DB_PASSWORD", "s"))
err:=f.InsertBefore("DB_HOST", dotenv.NewPair("DB_DRIVER", "postgres"))

Two families, and the difference matters

FamilySemanticsUse for
Set, SetAfter, SetBeforeupsert — guarantees one active entry; updates in place if the key existsediting
Append, InsertAfter, InsertBeforeliteral — places exactly what you pass, duplicates includedgenerating
f:=dotenv.Parse("A=1\n")
f.Set("A", "2") // A=2 — one entryf.Append(dotenv.NewPair("A", "2")) // A=1 \n A=2 — two entries, last wins

Blurring them is how a key silently moves.

Constructors

NewPair(key, value)a KEY=value entry; quoting is chosen when it is placed
NewComment(lines...)one line per argument, each prefixed # unless it already starts with #
NewBlank()an empty line, for separating sections

Constructed entries carry no rendered text until Append or Insert attaches them — the line ending belongs to the destination file, which the entry does not know yet. An entry taken from Entries() is already attached and is copied verbatim, so re-placing one preserves its exact original bytes.

Hazard. Appending to a file whose last value has an unterminated quote puts the new content inside that value, because the open quote keeps consuming lines. The source is already malformed and nothing here can fix it — a parser that guessed where the quote should have closed would corrupt legitimate multi-line values. Check the result when writing to files you did not author.

Disabled settings

A commented-out setting is its own kind, not prose:

# DB_USER=admin ← KindDisabledPair: Key and Value populated, INACTIVE# just a note ← KindComment

That holds regardless of who commented it out — by hand or via Unset — because the two mean the same thing:

for_, e:=rangef.Entries() {
switche.Kind {
casedotenv.KindPair: // activecasedotenv.KindDisabledPair: // turned off, but Key and Value are readablecasedotenv.KindComment: // prosecasedotenv.KindBlank:
casedotenv.KindOther: // unrecognised, preserved verbatim
}
}
f.Disabled() // []Pair of every commented-out setting, in file order

Disabled entries stay inactiveGet, Has, Count, Map, and Keys all ignore them, exactly as a consumer of the file would. The point is that a tool can now say "DB_USER is disabled" rather than "DB_USER is missing", which are different problems with different fixes.

The trade: prose shaped like an assignment is misfiled. # TODO=fix this reads as a disabled setting — which is also what a human skimming the file would assume. A commented value whose quote does not close on its own line stays prose, since a multi-line block cannot be reassembled from separate comment lines.

Turning one back on

f.Unset("DB_USER", false) // comment it out — reversiblef.Restore("DB_USER") // turn it back on

Restore removes the exact marker recorded for that entry, so #DB_USER=admin comes back without inventing a space, and an indented # DB_USER=admin keeps its indentation. Disable followed by restore returns the file to its original bytes — a fuzz property, not just a test case.

Two limits, both pinned by tests:

  • With more than one disabled entry for a key, Restore targets the last — consistent with Get, Set, and Unset, but not necessarily the one Unset just created.
  • A commented-out multi-line value cannot be restored from a re-read file: each of its lines parses as a separate comment. Within one session, where Unset kept the block together, it reverses completely.

Inherited declarations

Compose's env-file grammar allows a line that is just a name — no delimiter, no value:

DATABASE_URL=postgres://localhost/dev
HOME
AWS_SECRET_ACCESS_KEY

To Compose that means inherit this variable from the process environment — the idiom for whitelisting host variables into a container without ever writing their values into the file. This package recognises the declaration as its own kind, KindInherited, but never resolves it: reading os.Environ is exactly what this package promises not to do. So the entry is inactive — Get, Keys, and Map skip it, and a ${reference} to it expands like any other unset variable.

Inherited lists the declared names so an application that wants Compose's behaviour applies its own source, with its own precedence:

env:=f.Map()
for_, name:=rangef.Inherited() {
ifv, ok:=os.LookupEnv(name); ok { // the caller's decision, not the parser'senv[name] =v
}
}

The same layering the package prescribes for loading in general: the file describes, the application decides.

Cloning between environments

Deriving .env.prod from .env.staging is the case this package's read/write split was designed for.

staging, _:=dotenv.Open(".env.staging")
prod:=staging.Clone() // independent copyprod.Set("DOMAIN", "acme.io") // change only what differsprod.SaveAs(".env.prod") // write elsewhere; staging.Path is untouched

Given this source:

# ------------------------------# SHARED# ------------------------------DOMAIN=staging.acme.io# ------------------------------# DERIVED — these must stay as references# ------------------------------API_URL=https://api.${DOMAIN}CALLBACK=${API_URL}/oauth/callbackSECRET=${VAULT_TOKEN:?set me}

the clone comes out with one line changed and everything else byte-identical — banners, blank lines, references, and the required-variable guard all intact:

DOMAIN=acme.ioAPI_URL=https://api.${DOMAIN} ← still a referenceCALLBACK=${API_URL}/oauth/callback ← still chainedSECRET=${VAULT_TOKEN:?set me} ← still armed

Why references survive: Render never expands

f.Get("API_URL") // "https://api.${DOMAIN}" ← what is on diskf.GetExpanded("API_URL") // "https://api.staging.acme.io" ← derived, for consumers

Render and Save write onlyRaw. Expansion happens in exactly one method, and nothing in the write path calls it — so a clone keeps references literal by default. You would have to go out of your way to bake them in.

The cascade, and why it matters

A reference is a formula, not a value — like =A1*2 in a spreadsheet rather than a typed number. Store the formula and it re-evaluates; store the result and it is frozen.

Changing DOMAIN once, in a file that stored the formula:

on disk after the editAPI_URL resolves to
stored as a referencehttps://api.${DOMAIN}https://api.acme.io
stored bakedhttps://api.staging.acme.iohttps://api.staging.acme.io

The baked file is now wrong and silent: it still points at staging, nothing errors, and you find out when production traffic hits the staging API.

CALLBACK=${API_URL}/oauth/callback follows too, because it references API_URL which references DOMAIN. One edit, the whole chain re-resolves.

The trap. Building the new file from ExpandedMap() instead of cloning bakes every reference at once. The result looks correct on the day you write it and quietly stops tracking from then on.

Freezing a value on purpose

Sometimes you want a value pinned so it stops following its source — a secret resolved once at deploy time, say. That is a caller decision, so it is a recipe rather than an API:

v, _, _:=f.GetExpanded("DB_URL")
f.Set("DB_URL", v) // now literal; no longer follows DOMAIN

Several variants from one source

SaveAs does not mutate f.Path, so a single loaded file can emit as many as you need:

forenv, domain:=rangemap[string]string{"prod": "acme.io", "qa": "qa.acme.io"} {
v:=staging.Clone()
v.Set("DOMAIN", domain)
v.SaveAs(".env."+env)
}

Clone is an independent deep copy — editing either side leaves the other alone — and carries the file's line-ending style, mode, and parse options across. It is cheaper and more faithful than Parse(f.Render()), which re-parses and can reclassify entries on the second pass.

Permissions on SaveAs: an existing destination keeps its own mode; a new one gets the source file's. Overwriting must never widen a file somebody deliberately locked down, nor loosen one about to hold secrets.

Plugins

Decryption, secret stores, auditing, and save-time validation live outside this package while running inside its pipeline. A plugin is an object implementing whichever capability interfaces it needs — one plugin may hold several, sharing a client and a cache between them.

f, err:=dotenv.Open(".env", dotenv.WithPlugin(vault.New(client)))
f.Plugins() // vault: Lookuper, ValueTransformer

The six hooks

CapabilityWhenOrderingMay failAffects bytes
EntryObserveronce per entry during parseall, install ordernono
Lookupera ${VAR} the file does not definefirst non-miss winsyesno
ValueTransformerafter a value is expandedchained, each sees the last outputyesno
CommandRunnera $(cmd)last installed wins — a replacement, not a chainyesno
ExpandObserverevery reference resolutionall, install ordernono
SaveGuardbefore Save / SaveAs writesall; first error abortsyesveto only

The firewall

A hook may change what a value READS as. It may never change what gets WRITTEN.

Every value-changing hook lives in the expansion path, which Render and Save never call. So round-trip stays byte-exact regardless of which plugins are installed, and a decrypted secret can never be written back in plaintext. SaveGuard sits nearest the write path and may only return an error — it cannot rewrite.

That is structural rather than a convention, and it is asserted two ways: an example test rendering one file with every hook and with none, and FuzzPluginsNeverAffectBytes, which does the same for arbitrary input.

Worked examples

Environment fallback — Compose's behaviour, opted into rather than imposed:

dotenv.Parse(src, dotenv.WithLookup(os.LookupEnv))

The file always wins; a lookup only fills references the file does not define. A lookup error aborts rather than counting as a miss, because an unreachable secret store must not look like an unset variable — otherwise a deploy proceeds with an empty password.

Decryptionencrypted: parity as a plugin rather than a feature this package owns:

dotenv.WithValueTransform(func(key, vstring) (string, error) {
s, ok:=strings.CutPrefix(v, "encrypted:")
if!ok {
returnv, nil
}
returndecrypt(s)
})

Get still returns the ciphertext, so that is what gets written back. Only GetExpanded sees plaintext.

Refusing to save a plaintext secret:

dotenv.WithSaveGuard(func(f*dotenv.File) error {
for_, p:=rangef.Pairs() {
ifstrings.Contains(p.Key, "PASSWORD") &&!strings.HasPrefix(p.Value, "encrypted:") {
returnfmt.Errorf("%s looks like a plaintext secret", p.Key)
}
}
returnnil
})

A refusal leaves the destination exactly as it was — nothing is written, and no temp file is left beside it.

Dead-key detection — which declared variables nothing references:

func (t*Tracer) ObserveExpand(key, name, resolvedstring) { t.used[name] =true }

Observers see every entry, including KindDisabledPair and unrecognised lines: a commented-out setting is a finding for an auditor, not noise.

Writing a plugin

typeVaultstruct{ client*api.Client }
func (v*Vault) Name() string { return"vault" }
func (v*Vault) Lookup(namestring) (string, bool, error) { ... }
func (v*Vault) TransformValue(key, valstring) (string, error) { ... }
// REQUIRED. An optional interface with a wrong signature compiles, installs,// and never runs — this turns that silence into a build failure.var (
_ dotenv.Lookuper= (*Vault)(nil)
_ dotenv.ValueTransformer= (*Vault)(nil)
)

That last block is not optional style. func (v *Vault) LookUp(...) or a missing error return compiles fine and leaves the plugin inert. f.Plugins() reports what was actually detected, so a missing capability is visible — but a compile-time assertion catches it before it ships.

Capabilities are resolved once at construction into pre-filtered slices, so a plugin without a capability costs nothing on the expansion path.

Typed configuration

Every value here is a string, deliberately. To get int, bool, time.Duration, or a slice, hand the file to a binder — this package supplies the values, the binder supplies the types.

A *File satisfies sethvargo/go-envconfig's Lookuper interface with a three-line adapter, so the two compose with no dependency in either direction:

typefileLookuperstruct{ f*dotenv.File }
func (lfileLookuper) Lookup(keystring) (string, bool) {
v, ok, err:=l.f.GetExpanded(key)
iferr!=nil {
return"", false
}
returnv, ok
}
typeConfigstruct {
Portint`env:"PORT, default=8080"`Debugbool`env:"DEBUG"`Hosts []string`env:"HOSTS, delimiter=;"`Wait time.Duration`env:"WAIT, default=30s"`Tokenstring`env:"TOKEN, required"`
}
f, _:=dotenv.Open(".env")
err:=envconfig.ProcessWith(ctx, &cfg, fileLookuper{f})

dotenv handles comments, references, and round-tripping; the binder handles types, defaults, required fields, slices, and nested prefixes — and reports all binding errors at once rather than failing on the first.

caarlos0/env works the same way, reading from a map:

m, _:=dotenv.Read(".env")
err:=env.ParseWithOptions(&cfg, env.Options{Environment: m})

Why there is no f.Int("PORT", 8080)

A .env has no types. PORT=8443 is four characters, and which Go type it becomes depends on the field receiving it — the application's decision, not the format's. encoding/json draws the line in the same place: you unmarshal into a struct, there is no json.GetInt.

Typed getters also fail in the worst way available. PORT=eighty returns the default, silently, and the service comes up on the wrong port with nothing in the logs. A binder reports it as an error alongside every other problem in the file.

Inferring types from a value's shape8443 becoming a number because it looks numeric — was evaluated and rejected: ZIP=01234 loses its leading zero, DESCRIPTION=Hello, world becomes an array, and the usual escape hatches collide with backtick and glob syntax this package already supports.

Note ${VAR:?message} already covers required at the file level, so a missing value can fail during expansion rather than at bind time — useful when the variable is referenced by another value rather than bound directly.

Feature support

Every .env construct in common use, including the full Docker Compose interpolation spec. Each row is asserted by a test in conformance_test.go — this table reflects behaviour, not intent.

Quoting

FeatureExampleSupported
UnquotedSIMPLE=xyz123
Double-quotedVAR="VAL"
Single-quoted, literalVAR='VAL'
Backtick, literalVAR=`VAL`
Backtick multilineKEY=`line one
line two`
Double-quoted multilineKEY="line one
line two"
Spaces around =KEY = value
export prefixexport KEY=value
Empty valueKEY=
KEY: value — colon delimiterFOO: bar✅ — in the Compose grammar and Node dotenv's parser; the author's delimiter is preserved on edit
Key charset: letters, digits, _.-[]spring.datasource.url=x, 2FA_SECRET=x✅ — Compose's charset, a superset of Node dotenv's and godotenv's [\w.-]

Comments

FeatureExampleSupported
Whole-line comment# a note
After a quoted valueKEY="v" # note
# with no leading space is not a commentKEY=VAL# literal
# inside quotes is preservedKEY="a-#-b"
Blank lines
Unrecognised lines kept verbatimK!=v, HAS KEY=v✅ — deliberate divergence from compose-go, which errors and refuses the whole file; an editor must stay able to open, edit, and save around content it will never touch

Escape sequences

Only inside double quotes. Single-quoted and backtick values are fully literal.

EscapeEscapeExtended (default)EscapeCompose
\"""
\\\\
\n\r\t✅ decoded❌ kept literal
anything else (\d, C:\Users)kept literalkept literal

The two modes are genuinely incompatible — under Compose, \n stays two characters; under Extended it becomes a newline — so it is a choice rather than a default to work around:

f:=dotenv.Parse(content, dotenv.WithEscapes(dotenv.EscapeCompose))

Use EscapeCompose for files Docker Compose reads, and for values holding Windows paths or regexes where a backslash means itself.

Interpolation

Applied to unquoted and double-quoted values only. Covers the full Docker Compose set — the broadest in common use, so a .env written for any other tool also reads correctly here.

FormMeaningSupported
${VAR} / $VARdirect substitution
${VAR:-default}default when unset or empty
${VAR-default}default only when unset
${VAR:+alt}alt when set and non-empty
${VAR+alt}alt when set, even if empty
${VAR:?error}error when unset or empty
${VAR?error}error when unset
$$literal $$${VAR} yields ${VAR}
Nesting${VAR:-${FOO:-default}}
Chained / recursiveC=${B} where B=${A}
Cycles resolve to ""A=${B}, B=${A}
Unresolved → "", not an error${NOPE}
Single quotes suppress itB='${A}'${A}
Backticks suppress itB=`${A}` ${A}
${VAR/foo/bar} shell-style edits❌ not supported by Compose either

The two ? forms exist to fail loudly, so they are the only ones that produce an error:

v, ok, err:=f.GetExpanded("DATABASE_URL")
varre*dotenv.RequiredErroriferrors.As(err, &re) {
// "dotenv: required variable DB_PASSWORD is not set or is empty: set me in .env"returnerr
}

ExpandedMap stops at the first such error rather than returning a half-expanded map, because a partial map gives the caller no way to tell which values are trustworthy.

Expansion is hand-written rather than delegating to os.Expand, which cannot express $$ or nesting — it stops at the first }, so the inner default of ${A:-${B:-c}} swallows the outer one.

A reference that re-enters a variable already being expanded resolves to "", the same answer an unset variable gives and the only one that terminates. Two separate references to one variable are not a cycle: K=${A}-${A} expands both.

How $ is dispatched

One character decides, in a single left-to-right pass. The five cases are mutually exclusive:

After $MeaningExample → result
$literal dollarcost 5$$cost 5$
(command$(whoami) → command output
{braced variable${NAME}world
letter or _bare variable$NAME/xworld/x
anything elseliteral dollar100$ or so → unchanged

$${NAME} is row 1 feeding row 3: the scanner consumes $$, emits one $, and skips both bytes — so the {NAME} that follows is ordinary text. Result: ${NAME}, exactly as Compose does it.

All three forms mix freely in one value, and a command's text is expanded before it runs:

$USER-$(id)-${NAME} → ada-501-world
$(greet ${NAME}) → runs: greet world

That ordering is the shell's own, and it means ${VAR:?err} errors and the cycle guard both work through a command.

Command substitution

FeatureExampleSupported
$(command)URL="db://$(whoami)@host"opt-in

Off by default, deliberately. With it on, merely reading a config file executes arbitrary shell — so a .env from a repository, a container image, or a teammate becomes remote code execution. That is a decision the calling application makes about files it trusts, not something a parser should do silently.

f:=dotenv.Parse(content, dotenv.WithCommandSubstitution(true))
// Or supply a sandboxed / allow-listed runner instead of raw `sh -c`:f:=dotenv.Parse(content, dotenv.WithCommandRunner(myRunner))

A failing command expands to "", as a shell would inside a value, rather than failing the whole read.

Other

FeatureNotes
encrypted: valuesStored verbatim — this package does not decrypt. Pair it with your own key handling
CRLF filesRound-trip as CRLF
Missing trailing newlinePreserved
Duplicate keysLast wins, matching every dotenv implementation. Count surfaces them
Name-only lines (HOME)Recognised as a Compose inherited declaration — see below. Never resolved: this package does not read the environment

Variable expansion

Get returns the raw value. GetExpanded resolves references against the other entries in the same file — the interpolation Compose performs when it reads the file:

// ADMIN_PASS=${SECRET}raw, _:=f.Get("ADMIN_PASS") // "${SECRET}" — what is on diskval, ok, err:=f.GetExpanded("ADMIN_PASS") // "hunter2" — what a consumer seesall, err:=f.ExpandedMap()

Single-quoted and backtick values are never interpolated — SECRET='${A}' means the literal text ${A}. Expanding it would silently replace a value the author explicitly marked as literal.

Expansion is depth-bounded, so a reference cycle terminates instead of hanging the caller.

Use Get, not GetExpanded, for anything you write back. Expansion must feed what a consumer reads, never what gets persisted — otherwise a reference is flattened into a copy of its target and the link is lost.

Saving safely

.env files hold credentials, so Save:

  • writes a sibling temp file at 0600 first — never a wider window, even briefly
  • chmods it to the target mode, then renames over the destination

A rename within a directory is atomic, so a reader never observes a half-written credentials file, and a crash mid-save leaves the original intact. A failed save removes the temp file rather than leaving it beside the real one.

Existing file modes are preserved; new files are created 0600. The explicit chmod is necessary because os.WriteFile's mode argument is filtered by the process umask, so it cannot be relied on to reproduce the original permissions.

Line endings and trailing newlines

Terminators are preserved per line, not per file. A file with mixed endings round-trips as mixed rather than being normalised into one style, which would be a whole-file diff nobody asked for.

Lines this package writes use the file's dominant style, so appending to a CRLF file does not introduce a stray LF:

f:=dotenv.Parse("A=1\r\n")
f.Set("B", "2")
f.Render() // "A=1\r\nB=2\r\n"

A file that ended without a trailing newline still does not. Adding or removing one would be a spurious diff on every save.

API

SymbolPurpose
Read(path)the shortcut — straight to map[string]string, expanded
Open(path) / Parse(content) / ParseReader(r)read from disk / a string / any io.Reader
f.Get / Has / Countread one key
f.GetExpandedread one key, interpolated — returns (value, found, error)
f.Keys / Pairs / Map / ExpandedMapread all
f.Set / SetAfter / SetBeforeedit — upsert
f.Append / InsertAfter / InsertBeforegenerate — literal placement
NewPair / NewComment / NewBlankbuild entries to place
f.Unset / Restore / Disabledturn settings off and on
f.Inheritednames declared as inherited from the environment (never resolved here)
WithEscapes / WithCommandSubstitutiondialect options
WithPlugininstall a plugin
WithLookup / WithValueTransform / WithSaveGuard / WithCommandRunnersingle-function hooks
f.Pluginswhat each installed plugin was detected as
Plugin, Lookuper, ValueTransformer, CommandRunner, SaveGuard, EntryObserver, ExpandObservercapability interfaces
f.Render / Save / SaveAsserialize / write atomically / write elsewhere
f.Cloneindependent deep copy
f.Entriesevery entry, including comments and blanks
f.Existed / Mode / Pathfile facts
Kind (6 kinds), Entry, Pair, RequiredError, ErrAnchorNotFoundtypes and errors

Testing

Statement coverage100%
Subtests380+
Fuzz properties8
Dependencies0

Conformance is asserted by tests, not claimed — conformance_test.go has one assertion per syntax rule in the tables above.

Property-based fuzzing covers what example tests structurally cannot:

PropertyGuarantee
FuzzParseRenderparse → render returns the input byte for byte, for any input
FuzzParseIsIdempotentre-processing a file never drifts
FuzzSetRoundTripwhatever Set writes, the parser reads back identically
FuzzSetIsIdempotentrepeating an edit produces no churn
FuzzExpandTerminatesexpansion always halts and never panics
FuzzAppendRoundTripwhatever Append places, the parser reads back identically
FuzzDisableEnableIsReversibleUnset then Restore returns the file to its original bytes
FuzzPluginsNeverAffectBytesno plugin can change the rendered bytes, for any input
task dotenv:fuzz -- FuzzParseRender 30s # one property
task dotenv:fuzz:all # every property, 30s each
task dotenv:fuzz:list # what is available

Bugs the property tests found

Worth stating because all three passed 100% line coverage — coverage proves every line ran, not that it was right.

  1. Backtick values were corrupted. Adding backtick quoting to the parser did not teach the renderer about it, so Set(k, "x") wrote a bare value that parsed back as x. Caught by FuzzSetRoundTrip.
  2. Mixed line endings were normalised. A single whole-file CRLF flag turned "\r\n\n" into "\r\n\r\n". Terminators are now preserved per line. Caught by FuzzParseRender.
  3. Parsing was quadratic. The lookahead for multi-line values pre-trimmed carriage returns by copying the entire remaining line slice — once per pair. A 5 MB file took 23 seconds; trimming lazily brought it to 0.14. Found by measuring, not by a test, which is why TestParse_ScalesLinearly now exists.
  4. A=$A$A hung the process. A depth bound alone cannot stop a value that branches at every level — bounded at 32, that is still 2³² expansions. Cycles are now cut by variable name. Caught by FuzzExpandTerminates.

Two further properties surfaced hazards rather than bugs, both now pinned by their own tests: appending after an unterminated quote, and Restore picking the last of several disabled entries. Neither is fixable — the first is a malformed source, the second is the "last wins" rule this package applies everywhere — so they are documented instead of papered over.

Both failing inputs are checked in under testdata/fuzz/, so they run on every go test forever.

About

Comment-preserving .env parser and editor for Go — byte-identical round-trips, Docker Compose conformance

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages