skip to content

What does setting exec.Cmd.Env do to a Go child process, and which PATH resolves the binary?

level: middleimportance: should knowfreq 41%

answer

  1. all-or-nothing, not a merge
  2. what a curated slice silently drops
  3. duplicate keys, and which one counts
  4. the lookup happens before you set fields
  5. LookPath reads the parent's PATH

basics

~20 s

A nil Cmd.Env means the child inherits the parent's whole environment. Setting it replaces the environment entirely, not adds to it. The binary is still resolved by exec.LookPath using the calling process's PATH, which Cmd.Env cannot change.

solid answer

~50 s

`Cmd.Env` is all-or-nothing: leave it nil and the child gets `os.Environ()`, set it and the child gets exactly the entries you listed and nothing else — so a curated slice silently drops `PATH`, `HOME`, `TZ` and everything the tool quietly relies on. To add a variable you build on the inherited set, `append(os.Environ(), "DEPLOY_ENV=staging")`, or use `cmd.Environ()`. Duplicate keys are allowed and the last one wins, which is how you override an inherited value. The trap is resolution: `exec.Command` calls `exec.LookPath` immediately, using the **calling** process's `PATH`, so a `PATH` entry inside `Cmd.Env` changes what the child sees but not which file is executed. `Cmd.Dir` sets the child's working directory, and a relative `Cmd.Path` is interpreted relative to it — but it plays no part in the `PATH` search either. To control the choice, resolve it yourself with `exec.LookPath` or pass an absolute path.

code

go · 8 lines
go
bin, err := exec.LookPath("deployctl")
if err != nil {
	return fmt.Errorf("deployctl not on PATH: %w", err)
}
cmd := exec.Command(bin, "apply", "-f", "service.yaml")
cmd.Dir = workdir                                    // child's working directory
cmd.Env = append(os.Environ(), "DEPLOY_ENV=staging") // inherit, then override
out, err := cmd.CombinedOutput()

go deeper

for a junior

Recall that leaving Cmd.Env nil inherits the parent's environment and that setting it replaces the lot. Know that Cmd.Dir is where the child runs, not where its program is found.

for a middle

Explain the append-to-os.Environ idiom, the last-duplicate-wins rule, and why exec.Command resolves the binary from the calling process's PATH before any field you set takes effect.

for a senior

Diagnose the classic 'works in my shell' report from environment differences, and design startup checks with exec.LookPath so a missing or wrong binary fails before any state changes.

for a principal

Decide what the environment contract with orchestrated tools is: which variables are guaranteed, which are deliberately stripped, and where secrets are allowed to travel.

## `Cmd.Env` replaces; it never merges The field is documented as the environment of the process: "If Env is nil, the new process uses the current process's environment." The word to notice is *uses*, not *adds to*. Once you assign a non-nil slice, that slice **is** the environment. ```go cmd := exec.Command(bin, "apply") cmd.Env = []string{"DEPLOY_ENV=staging"} ``` That child runs with exactly one variable. No `PATH` (so any program *it* launches by bare name will fail), no `HOME` (so tools that read a config file from the home directory silently fall back to defaults), no `TZ`, no proxy settings, no terminal type. The failure this produces is nearly always confusing, because the tool works perfectly when a human runs it. Two correct patterns: ```go cmd.Env = append(os.Environ(), "DEPLOY_ENV=staging") // inherit, then add cmd.Env = append(cmd.Environ(), "DEPLOY_ENV=staging") // same, via the Cmd ``` `Cmd.Environ()` returns the environment the command *would* run with, which is the parent's set when `Env` is nil, adjusted for `Dir`. Building on it keeps you consistent with whatever else you have already configured. ### Duplicates: last wins The slice is a plain `[]string` of `KEY=VALUE` entries and nothing deduplicates it. If the same key appears twice, the later entry is the effective one. That is exactly what makes the `append` idiom work — you are appending an override after the inherited copy — and it is also why you should not assume the slice you built is minimal. ### Deliberately minimal environments Sometimes an empty-ish environment is what you want: reproducible builds, or a step that must not see a token in the parent's environment. Do it consciously and carry over what the tool actually needs — typically `PATH`, `HOME` and `TZ` — rather than discovering the list one incident at a time. ## `Cmd.Dir` `Dir` is the working directory the child starts in; empty means the calling process's current directory. Setting it is strongly preferable to calling `os.Chdir` in your own program, because the working directory is process-wide state and a concurrent goroutine launching another command would see it change underneath. One subtlety: if `Cmd.Path` is a **relative** path, it is evaluated relative to `Dir`. So `exec.Command("./tool")` with `cmd.Dir = "/opt/pkg"` runs `/opt/pkg/tool`, not a `tool` in your own directory. ## Which `PATH` finds the program This is the part that surprises people. `exec.Command(name, ...)` resolves `name` **at construction time**, before you have had a chance to set any field: - If `name` contains a path separator, it is used as-is (and, if relative, resolved against `Dir` as described above). - Otherwise `exec.LookPath(name)` searches the `PATH` of the **calling** process — the value in your own environment right now. So `cmd.Env = append(os.Environ(), "PATH=/opt/pinned/bin")` does *not* change which binary runs. It changes the `PATH` the child sees, which affects programs *it* launches, but the file already chosen for `Cmd.Path` was picked from your `PATH`. If you want a specific build, do the resolution explicitly: ```go bin, err := exec.LookPath("deployctl") // fail fast, with a clear message if err != nil { return fmt.Errorf("deployctl not installed: %w", err) } cmd := exec.Command(bin, "apply") ``` or set `os.Setenv("PATH", ...)` in the parent before constructing the `Cmd`, or simply pass an absolute path. `exec.LookPath` also declines to resolve a program through a relative entry in `PATH` such as `.` or an empty element. It returns `exec.ErrDot` instead, so a program in the current directory is not silently preferred over one on the system path; if you really mean the local file, name it `./tool`. ## Failing fast Because `exec.Command` stashes the lookup failure in `Cmd.Err` rather than returning it, a missing binary does not surface until you run the command — often deep inside a workflow, after other steps have already changed state. A program that shells out to tools it does not own is better off resolving every one of them with `exec.LookPath` at startup and refusing to begin, with a message naming the tool, than discovering a `PATH` problem halfway through.

  • A step works interactively but the Go orchestrator's child cannot find its own config. What do you check first?
    Whether `Cmd.Env` was set to a curated slice. Replacing the environment drops `HOME`, so a tool that reads a dotfile from the home directory falls back to defaults and reports nothing missing. Rebuild the slice as `append(os.Environ(), ...)`, or add `HOME` explicitly if a minimal environment is intentional.
  • Why prefer cmd.Dir over calling os.Chdir before running the command?
    The working directory is process-wide state shared by every goroutine. Changing it races with any concurrent work and leaves your own program somewhere unexpected if the command panics or returns early. `Cmd.Dir` applies to that one child only and needs no restoration.
  • How do you make a missing external tool fail loudly at startup rather than mid-run?
    Call `exec.LookPath` for every binary you depend on when the program starts, and refuse to run with an error naming the tool that is missing. Otherwise the lookup error sits in `Cmd.Err` until the first `Run`, which may be after earlier steps have already mutated real state.

saying these in an interview costs you the question

  • Thinks Cmd.Env is merged with the parent's environment
  • Expects a PATH inside Cmd.Env to select the binary
  • Believes Cmd.Dir is searched for the executable
  • Calls os.Chdir instead of setting Cmd.Dir
  • Assumes a missing binary errors from exec.Command itself