Skip to content
CalliCoder

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>/environ is readable by the process owner and by root.
  • A crash reporter or a panic handler that dumps state can include it.
  • docker inspect shows 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.