skip to content

Environment and Working Directory

os.LookupEnv tells empty apart from unset where os.Getenv cannot, and os.Chdir moves state for the whole process, which is why t.Setenv and t.Chdir exist and why neither survives t.Parallel.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

questions

4

In Go, what is the difference between os.Getenv and os.LookupEnv?

level: juniorimportance: must knowfreq 70%

answer

  1. one returns a value, one returns two
  2. empty string, or not there at all?
  3. the comma-ok form, like a map lookup
  4. os.Getenv cannot tell the two apart

basics

~10 s

os.Getenv returns only the value, so an unset variable and one set to the empty string both come back as "". os.LookupEnv returns the value plus a presence boolean, keeping those two cases distinct.

solid answer

~40 s

`os.Getenv(key) string` gives you the value and nothing else: a missing variable and a variable explicitly set to the empty string both yield `""`. `os.LookupEnv(key) (string, bool)` returns the same value plus a presence flag, so `if v, ok := os.LookupEnv("APP_COLOR"); ok` is true even when `v == ""`. Use `Getenv` when "empty" and "absent" should mean the same thing and you are just falling back to a default. Use `LookupEnv` when absence is meaningful — for example a variable that is deliberately set to empty to override a compiled-in default, or one whose presence alone switches behaviour. Neither call ever returns an error, and both read a process-wide table that any goroutine can see.

code

go · 8 lines
go
// Both an unset APP_COLOR and APP_COLOR="" print true here.
fmt.Println(os.Getenv("APP_COLOR") == "")

if v, ok := os.LookupEnv("APP_COLOR"); ok {
	fmt.Println("explicitly set to:", v) // v may legitimately be ""
} else {
	fmt.Println("not set at all")
}

go deeper

for a junior

Be ready to state both signatures from memory and say which one you would reach for when a variable has a sensible default. Knowing that neither returns an error is part of the answer.

for a middle

Explain the mechanics: one process-wide table of strings, os.Environ handing it back as KEY=value entries, and os.ExpandEnv turning an unset name into an empty string with no complaint.

for a senior

Show the production judgment — read the environment once at the edge of the program and pass typed values inward, so that no deep package secretly depends on a global table that a test or another goroutine can change.

for a principal

Own the convention: which variables exist, whether an empty value is a legal way to say off, and how that contract is documented so operators and on-call engineers are not guessing at deploy time.

Every operating-system process starts with a table of string key/value pairs — its environment — copied from whatever launched it. Go exposes that table through a few functions in the `os` package, and the whole `Getenv` versus `LookupEnv` question turns on one fact: the table can hold a key whose value is the empty string, and that is a genuinely different state from the key not being there. ## The two signatures - `os.Getenv(key string) string` — returns the value for `key`, or `""` if `key` is not present. One return value, no error, no way to distinguish the two cases. - `os.LookupEnv(key string) (string, bool)` — returns the value and a boolean that is `true` only if the key was actually present. This is the same comma-ok shape Go uses for map lookups and type assertions. ## When the difference actually matters Most of the time it does not, and `os.Getenv` is the right, shorter call: port := os.Getenv("PORT") if port == "" { port = "8080" } Here "unset" and "set to empty" should both mean "use the default", so collapsing them is correct. It stops being correct as soon as an empty value carries meaning. Suppose a program has a built-in proxy address and lets an operator disable it by exporting an empty override. With `os.Getenv` that override is indistinguishable from never having set the variable, so the built-in default wins and the operator's intent is silently discarded. With `os.LookupEnv` the presence flag records that somebody made a decision: proxy := defaultProxy if v, ok := os.LookupEnv("APP_PROXY"); ok { proxy = v // may be "", meaning "no proxy" } The same reasoning applies to any variable whose mere presence is the signal — a debug switch, a "running inside CI" marker, a feature toggle. ## Reading the whole table `os.Environ() []string` returns the entire environment as a slice of strings, each of the form `"KEY=value"`. It is not a map, and it is not a slice of structs; if you want a map you build one yourself. Split on the **first** `=` only, because a value may legitimately contain more of them: for _, kv := range os.Environ() { k, v, _ := strings.Cut(kv, "=") _ = k _ = v } The order of the entries is not meaningful and should not be relied on. ## Expansion `os.ExpandEnv(s string) string` rewrites `$VAR` and `${VAR}` references inside a string using `os.Getenv`. Because it goes through `Getenv`, **a reference to an unset variable becomes the empty string, silently** — `os.ExpandEnv("host=$DB_HOST")` with `DB_HOST` unset produces `"host="`, not an error and not the untouched text. That is a classic way to end up with an empty password or an empty hostname deep inside a connection string. When you want control, `os.Expand(s string, mapping func(string) string) string` lets you supply the lookup function yourself, so you can record which names were missing, or substitute your own defaults. ## Mutation is process-wide `os.Setenv(key, value string) error`, `os.Unsetenv(key string) error` and `os.Clearenv()` change *this process's* copy of the table. Three consequences follow: 1. The change is visible immediately to every goroutine and every package in the program — there is no per-goroutine environment. 2. It does not travel back to the parent process. Exporting a variable from inside a Go program cannot change the shell that started it. 3. The `os` package serialises access to the table internally, so a concurrent `Getenv` and `Setenv` is not a data race in the race detector's sense — but it is still shared mutable global state, and one part of the program can observe a value another part changed mid-flight. That is why the environment is best read once, near the start of `main`, and then passed onward as ordinary values. ## The idiom to remember Wrap the pattern once rather than scattering it: func envOr(key, fallback string) string { if v, ok := os.LookupEnv(key); ok { return v } return fallback } That single helper makes the "empty means empty" decision explicit and puts it in one place, which is exactly what an interviewer is checking you understand.

  • How do you apply a default when a variable is missing, while still honouring a deliberately empty value?
    Use `os.LookupEnv` and fall back only when the boolean is false: `if v, ok := os.LookupEnv(k); ok { return v }; return fallback`. With `os.Getenv` you cannot express that, because an empty override and a missing variable are the same result — the fallback would overwrite the operator's explicit choice.
  • What does os.ExpandEnv do with a reference to a variable that is not set?
    It replaces `$VAR` or `${VAR}` with the empty string, silently — no error, and the original text is not left in place. That is how an empty hostname or password gets baked into a connection string. `os.Expand` takes your own mapping function instead, so you can report missing names or supply defaults.
  • If one goroutine calls os.Setenv, do the others see it?
    Yes, immediately — there is one environment per process, not per goroutine or per package. The `os` package serialises access internally so the race detector will not flag it, but it is still shared mutable state. Read the environment once near the start of `main` and pass the values on as parameters.

saying these in an interview costs you the question

  • Says os.Getenv returns an error when the variable is missing
  • Claims os.LookupEnv returns (string, error) rather than (string, bool)
  • Treats a missing variable and an empty one as always identical
  • Thinks os.Environ returns a map[string]string
  • Believes os.Setenv changes the environment of the parent shell
open as a page

Why is calling os.Chdir inside a Go library or long-running server risky?

level: middleimportance: should knowfreq 45%

basics

~10 s

The working directory is one attribute of the whole process, not something each goroutine owns. os.Chdir changes how every relative path in the program resolves, including paths other goroutines are opening at that instant.

open as a page

A Go package's tests pass individually under go test -run but fail when the whole package runs. How do you diagnose it?

level: seniorimportance: should knowfreq 42%

basics

~20 s

Treat it as order dependence, not flakiness: one test leaves process-global state behind, usually an environment variable or the working directory. Reproduce with go test -count=2 and -shuffle=on, then undo per test with t.Setenv and t.Chdir.

open as a page

Why does os.Open("config.yaml") work in local development but fail when the same Go binary runs as a service?

level: middleimportance: nice to knowfreq 28%

basics

~20 s

A relative filename resolves against the process's working directory, inherited from whatever started the program: your shell in the source folder locally, often / under a service manager. The binary's own location is separate, reported by os.Executable.

open as a page