Reading and Writing Environment Variables in Go
Published Updated Golang 12 min read
LookupEnv distinguishes unset from empty and Getenv does not, Setenv only changes this process, and an environment variable is readable by anything that can read /proc.
os.Getenv returns an empty string for a variable that is set to nothing and for one that was never
set at all. Those are different situations, “the operator explicitly cleared this” against “use the
default”, and telling them apart needs the other function.
Written against Go 1.22.
Reading
package main
import (
"fmt"
"os"
)
func main() {
home := os.Getenv("HOME") // "" if unset
fmt.Println(home)
if port, ok := os.LookupEnv("PORT"); ok {
fmt.Println("PORT is set to", port) // possibly ""
} else {
fmt.Println("PORT is not set")
}
}
LookupEnv returns the value and a boolean. Use it whenever “unset” and “empty” should behave
differently, which is most configuration:
func envOr(key, fallback string) string {
if v, ok := os.LookupEnv(key); ok {
return v
}
return fallback
}
Written with Getenv instead, that helper cannot be told to use an empty value. Setting PREFIX=""
would silently restore the default, which is the opposite of what the operator asked for.
Writing, and what it does not do
os.Setenv("APP_MODE", "test")
os.Unsetenv("APP_MODE")
os.Clearenv()
All three affect this process only. There is no way to change the parent shell’s environment from
a program, the environment is copied at fork, and the child gets its own. A program that appears to
export a variable is printing shell commands for the caller to eval.
Child processes started afterwards do inherit the change, since they are forked from this process:
os.Setenv("LANG", "C")
out, err := exec.Command("date").Output() // sees LANG=C
To set a variable for one child without touching the parent, populate cmd.Env instead:
cmd := exec.Command("date")
cmd.Env = append(os.Environ(), "LANG=C")
Assigning cmd.Env replaces the whole environment rather than adding to it, which is why the
append(os.Environ(), ...) form is the usual one. Setting it to a bare []string{"LANG=C"} gives
the child no PATH, and the failure, exec: "date": executable file not found, does not point at
the cause.
Listing everything
for _, entry := range os.Environ() {
key, value, _ := strings.Cut(entry, "=")
fmt.Printf("%s = %s\n", key, value)
}
os.Environ returns KEY=value strings. strings.Cut splits on the first =, which matters
because a value can contain one: splitting with strings.Split and taking index 1 truncates any
value containing an equals sign.
Never log the full environment. It routinely contains credentials, and a log line dumping it is a credential leak into whatever aggregates the logs.
Expansion
os.Setenv("USER", "alice")
fmt.Println(os.ExpandEnv("home is /home/$USER")) // home is /home/alice
fmt.Println(os.ExpandEnv("literal $$USER")) // literal $USER
ExpandEnv substitutes $VAR and ${VAR}, replacing an unset variable with an empty string rather
than reporting it. That silence is a hazard in a configuration template: a typo in a variable name
produces a path like /data//cache instead of an error.
os.Expand takes a mapping function, which is how to add the missing check:
missing := []string{}
result := os.Expand(template, func(key string) string {
v, ok := os.LookupEnv(key)
if !ok {
missing = append(missing, key)
}
return v
})
Neither performs shell expansion, no $(command), no globbing, no ${VAR:-default}. Only the two
plain forms.
Typed configuration
Environment variables are strings, so everything else is a conversion that can fail:
type Config struct {
Port int
Debug bool
Timeout time.Duration
}
func Load() (Config, error) {
cfg := Config{Port: 8080, Timeout: 30 * time.Second}
if v, ok := os.LookupEnv("PORT"); ok {
p, err := strconv.Atoi(v)
if err != nil {
return cfg, fmt.Errorf("PORT: %w", err)
}
cfg.Port = p
}
if v, ok := os.LookupEnv("DEBUG"); ok {
b, err := strconv.ParseBool(v) // accepts 1, t, T, TRUE, true, and the false set
if err != nil {
return cfg, fmt.Errorf("DEBUG: %w", err)
}
cfg.Debug = b
}
if v, ok := os.LookupEnv("TIMEOUT"); ok {
d, err := time.ParseDuration(v) // "30s", "2m"
if err != nil {
return cfg, fmt.Errorf("TIMEOUT: %w", err)
}
cfg.Timeout = d
}
return cfg, nil
}
Verbose, and the verbosity buys something: every failure names the variable. Loading configuration
once at startup and failing immediately is far better than discovering at the first request that
PORT was "80 80".
strconv.ParseBool is worth preferring over comparing to "true", because it accepts the forms
people actually write: 1, yes is not among them, which is itself worth knowing.
Secrets
An environment variable is more private than a command-line argument and not private:
/proc/<pid>/environis readable by the process owner and by root.- A crash reporter or a panic handler that dumps state can include it.
docker inspectshows the variables a container was started with.- Any dependency in the process can call
os.Environ().
That is acceptable for many deployments and not for all. The stronger pattern is a secret in a file whose path is the environment variable: the file’s permissions then control access, and the value never enters the process table. It is what Docker secrets and Kubernetes secret volumes do.
func secret(key string) (string, error) {
if path, ok := os.LookupEnv(key + "_FILE"); ok {
b, err := os.ReadFile(path)
return strings.TrimSpace(string(b)), err
}
return os.Getenv(key), nil
}
TrimSpace matters: a file written with a text editor ends in a newline, and a token with a trailing
newline fails authentication in a way that looks like a wrong token.
Naming, and the twelve-factor argument
Two conventions are worth following because tooling assumes them. Names are uppercase with
underscores, and they are prefixed with the application, APP_PORT rather than PORT, because the
environment is a flat global namespace shared with every other program on the machine. An unprefixed
PORT or HOST will eventually collide with something.
The reason configuration lives here at all is that it is the one channel every deployment target supports identically: a shell, a systemd unit, a Dockerfile, a Kubernetes manifest and a CI runner all set environment variables, and none of them agree on config file locations. That is the whole of the twelve-factor argument, and it holds well for a dozen values.
It holds badly past that. A flat namespace of strings has no structure, no types, no comments and no way to express a list or a nested object without inventing an encoding. Once the configuration has sections, a file read at startup (with the file’s path supplied by an environment variable, and individual secrets still injected as variables) is easier to review and easier to diff.
The practical division: environment variables for what differs between deployments and for secrets; a file for what differs between products; flags for what differs between invocations.
Tests
func TestLoad(t *testing.T) {
t.Setenv("PORT", "9000") // restored automatically when the test ends
cfg, err := Load()
// ...
}
t.Setenv handles the cleanup and, importantly, fails the test if it runs in parallel. The
environment is process-wide, so a parallel test mutating it corrupts its neighbours.
The better answer for anything beyond a small program is not to read the environment deep inside the
code at all. A Load() that reads it once at startup and returns a struct means every other function
takes its configuration as a parameter and needs no environment at all in its tests.
Related: command-line flags. More in the Golang guides.
Frequently asked questions
What is the difference between Getenv and LookupEnv?
Getenv returns "" for both an unset
variable and an empty one. LookupEnv also returns a boolean, which is the only way to tell them
apart.
Can a Go program change the shell’s environment?
No, the environment is copied when the process
starts; os.Setenv affects this process and the children it starts afterwards, never the parent.
How do I set a variable for one child process?
Assign cmd.Env. It replaces the whole
environment, so use append(os.Environ(), "KEY=value") unless a bare environment is intended.
Why can’t the child find its executable after I set cmd.Env?
The replacement environment has no
PATH. Append to os.Environ() rather than assigning a one-element slice.
How do I list all environment variables?
os.Environ() returns KEY=value strings. Split with
strings.Cut on the first =, since values may contain one, and do not log the result.
Does ExpandEnv report unknown variables?
No, it substitutes an empty string. Use os.Expand with
a mapping function to detect and report them.
Does Go expand shell syntax in environment values?
No. $VAR and ${VAR} only, no command
substitution, no defaults, no globbing.
How do I read a boolean from the environment?
strconv.ParseBool, which accepts 1, t, true
and their false counterparts. It rejects yes, which is worth documenting for operators.
Are environment variables safe for secrets?
Safer than arguments, not safe. /proc/<pid>/environ,
docker inspect and any library in the process can read them. Prefer a file path in the variable and
the secret in the file.
How do I set an environment variable in a test?
t.Setenv, which restores the previous value
afterwards and refuses to run in a parallel test, since the environment is process-wide.