dotenv

package module
v0.1.1 Latest Latest
Warning

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

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

README

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.

import "github.com/ubgo/dotenv"
go install github.com/ubgo/dotenv/cli/cmd/dotenvctl@main

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 belowcli/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 (unsetrestore) ✅ 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")   // map[string]string, references resolved

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

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

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

v, ok := f.Get("DATABASE_URL")
f.Has("DATABASE_URL")
f.Keys()      // active keys, in file order, deduplicated
f.Pairs()     // active pairs, in file order, duplicates included
f.Map()       // effective view: last occurrence wins

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

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

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

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

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

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

Cost at scale

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

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 is a path-resolution library's job, not this package's:

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

Writing

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

Placing a new key

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

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

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

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

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

Generating a file

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

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

# ------------------
# AUTH
# ------------------
AUTH_SECRET="change me"

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

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

Two families, and the difference matters

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
f := dotenv.Parse("A=1\n")
f.Set("A", "2")                      // A=2          — one entry
f.Append(dotenv.NewPair("A", "2"))   // A=1 \n A=2   — two entries, last wins

Blurring them is how a key silently moves.

Constructors

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

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

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

Disabled settings

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

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

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

for _, e := range f.Entries() {
    switch e.Kind {
    case dotenv.KindPair:         // active
    case dotenv.KindDisabledPair: // turned off, but Key and Value are readable
    case dotenv.KindComment:      // prose
    case dotenv.KindBlank:
    case dotenv.KindOther:        // unrecognised, preserved verbatim
    }
}

f.Disabled()   // []Pair of every commented-out setting, in file order

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

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

Turning one back on

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

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

Two limits, both pinned by tests:

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

Inherited declarations

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

DATABASE_URL=postgres://localhost/dev
HOME
AWS_SECRET_ACCESS_KEY

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

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

env := f.Map()
for _, name := range f.Inherited() {
    if v, ok := os.LookupEnv(name); ok {   // the caller's decision, not the parser's
        env[name] = v
    }
}

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

Cloning between environments

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

staging, _ := dotenv.Open(".env.staging")

prod := staging.Clone()          // independent copy
prod.Set("DOMAIN", "acme.io")    // change only what differs
prod.SaveAs(".env.prod")         // write elsewhere; staging.Path is untouched

Given this source:

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

# ------------------------------
# DERIVED — these must stay as references
# ------------------------------
API_URL=https://api.${DOMAIN}
CALLBACK=${API_URL}/oauth/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")          // "https://api.${DOMAIN}"        ← what is on disk
f.GetExpanded("API_URL")  // "https://api.staging.acme.io"  ← derived, for consumers

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:

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

Several variants from one source

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

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

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

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

Plugins

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

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

The six hooks

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 yes 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: an example test rendering one file with every hook and with none, and FuzzPluginsNeverAffectBytes, which does the same for arbitrary input.

Worked examples

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

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

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

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

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

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

Refusing to save a plaintext secret:

dotenv.WithSaveGuard(func(f *dotenv.File) error {
    for _, p := 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
})

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

Dead-key detection — which declared variables nothing references:

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

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

Writing a plugin

type Vault struct{ client *api.Client }

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

func (v *Vault) Lookup(name string) (string, bool, error)       { ... }
func (v *Vault) TransformValue(key, val string) (string, error) { ... }

// REQUIRED. An optional interface with a wrong signature compiles, installs,
// and never runs — this turns that silence into a build failure.
var (
    _ dotenv.Lookuper         = (*Vault)(nil)
    _ dotenv.ValueTransformer = (*Vault)(nil)
)

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

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

Typed configuration

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

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

type fileLookuper struct{ f *dotenv.File }

func (l fileLookuper) Lookup(key string) (string, bool) {
    v, ok, err := l.f.GetExpanded(key)
    if err != nil {
        return "", false
    }
    return v, ok
}
type Config struct {
    Port  int           `env:"PORT, default=8080"`
    Debug bool          `env:"DEBUG"`
    Hosts []string      `env:"HOSTS, delimiter=;"`
    Wait  time.Duration `env:"WAIT, default=30s"`
    Token string        `env:"TOKEN, required"`
}

f, _ := dotenv.Open(".env")
err := envconfig.ProcessWith(ctx, &cfg, fileLookuper{f})

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

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

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

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:

cfg, res, err := cfgkit.Load[Config](cfgkit.DefaultSources())

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 shape8443 becoming a number because it looks numeric — was evaluated and rejected: ZIP=01234 loses its leading zero, DESCRIPTION=Hello, world becomes an array, and the usual escape hatches collide with backtick and glob syntax this package already supports.

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

Feature support

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

Quoting

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 is preserved on edit
Key charset: letters, digits, _ . - [ ] spring.datasource.url=x, 2FA_SECRET=x ✅ — Compose's charset, a superset of Node dotenv's and godotenv's [\w.-]

Comments

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:

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

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

Interpolation

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

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 resolve to "" A=${B}, B=${A}
Unresolved → "", not an error ${NOPE}
Single quotes suppress it B='${A}'${A}
Backticks suppress it B=`${A}` ${A}
${VAR/foo/bar} shell-style edits ❌ not supported by Compose either

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

v, ok, err := f.GetExpanded("DATABASE_URL")

var re *dotenv.RequiredError
if errors.As(err, &re) {
	// "dotenv: required variable DB_PASSWORD is not set or is empty: set me in .env"
	return err
}

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

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

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

How $ is dispatched

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

After $ Meaning Example → result
$ literal dollar cost 5$$cost 5$
( command $(whoami) → command output
{ braced variable ${NAME}world
letter or _ bare variable $NAME/xworld/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.

f := dotenv.Parse(content, dotenv.WithCommandSubstitution(true))

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

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

Other

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:

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

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

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

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

Saving safely

.env files hold credentials, so Save:

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

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

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

Line endings and trailing newlines

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

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

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

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

API

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.

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 dotenv:fuzz -- FuzzParseRender 30s   # one property
task dotenv:fuzz:all                      # every property, 30s each
task dotenv:fuzz:list                     # what is available

Bugs the property tests found

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

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

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

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

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. The file satisfies the first two's lookup interfaces with a three-line adapter. 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.

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.

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.

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.

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.

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 "##".

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.

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.

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.

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.

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.

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.

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"),
)

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.

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.

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.

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.

func (*File) Existed

func (f *File) Existed() bool

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

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.

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.

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.

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.

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.

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.

func (*File) InsertBefore

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

InsertBefore is InsertAfter, placing entries immediately before the anchor.

func (*File) Keys

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

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

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.

func (*File) Mode

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

Mode returns the file mode Save will apply.

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.

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

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.

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.

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.

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, spacing, 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, and a value containing newlines becomes a double-quoted multi-line block

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.

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.

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.

type Kind

type Kind int

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

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.

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.

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.

func WithEscapes

func WithEscapes(m EscapeMode) Option

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

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.

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.

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.

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)
}))

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.

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.

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.

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