Skip to content
Shell & TerminalDeep Dive Published Updated 5 min readViews unavailable

Environment Variables, Exports, and Subshell Boundaries

Shell variables and the process environment overlap but differ. Export, fork/exec, subshells, pipelines, sourcing, and Shellshock define where changes survive.

A shell stores variables for its own evaluation, while every process receives a separate environment: a collection of name-value strings copied from its parent. Confusing those two layers produces many “the variable disappeared” bugs.

Assignment is local until export

project=demo
sh -c 'printf "%s\n" "$project"'   # empty

export project
sh -c 'printf "%s\n" "$project"'   # demo

The first assignment creates a shell variable. export marks the name for inclusion in environments created for future commands. It does not create a shared global variable; the child gets a copy and cannot modify the parent’s value.

A temporary assignment applies only to one command’s environment:

LC_ALL=C sort names.txt

This is often safer than changing the interactive shell’s locale for everything that follows.

A subshell cannot send assignments back

Parentheses explicitly create a subshell environment in POSIX shells:

where=$PWD
(cd /tmp; where=$PWD; printf '%s\n' "$where")
printf '%s\n' "$where"

The directory and variable changes inside parentheses disappear when that environment ends. Command substitutions also execute in a subshell environment. Pipeline components commonly do as well, though extension behavior differs among shells; never rely on a variable assigned inside a pipeline being available afterward in portable code.

Sourcing is intentionally different

. ./settings.sh

The dot command, also spelled source in some shells, evaluates a file in the current shell. Its assignments, functions, cd, and options can therefore persist. That power is why a sourced file must be trusted and should avoid calling exit unexpectedly.

Inspect the right layer

set displays shell state and often much more; export -p shows exported names; env prints the environment passed to an external program. /proc/PID/environ on Linux is a process snapshot, not proof of the current contents of another shell’s unexported variables.

Secrets deserve special care. Environment variables may be visible to same-user process inspection, crash reporting, or diagnostic tools, and they propagate automatically to descendants. Use scoped assignments or secret-specific file descriptors where the application supports them, then unset values that no longer need to remain in the shell.

Where the parent/child boundary actually comes from

The fork/exec model explains why export exists at all. fork() duplicates the calling process, including a copy of its environment array; execve() then replaces that copy’s program image while keeping the environment intact. A shell variable that was never exported is not part of that array in the first place, so no descendant process — no matter how it is started — ever receives it. This is not a shell-specific rule; it is a direct consequence of how every Unix-family kernel hands a new program its initial state.

Shellshock: what happens when the environment carries more than data

The 2014 Shellshock vulnerability (CVE-2014-6271) is the clearest illustration of why treating the environment as “just variables” understates what it actually carries. A Bash extension let an environment variable encode not just a value but an entire shell function definition, imported automatically whenever a new Bash instance started. An attacker able to set an arbitrary environment variable — through a CGI script, an OpenSSH ForceCommand, or a DHCP client, among other vectors — could append shell commands after a function definition’s closing brace, and Bash would execute them during its own startup, before the target script had run a single line. The fix constrained how Bash parses function-shaped values arriving through the environment; the underlying lesson is that environment content silently crosses trust boundaries that a shell’s own source file does not.

Pipelines: Bash’s lastpipe is the deliberate exception

The earlier warning against relying on pipeline-assigned variables has one specific, documented exception. Bash’s shopt -s lastpipe, combined with job control being off (the normal case inside a non-interactive script), runs a pipeline’s final command in the current shell rather than a subshell:

shopt -s lastpipe
count=0
grep -c error logfile | while read -r n; do
  count=$n
done
echo "$count"   # prints the real value, because lastpipe kept this in-shell

Zsh behaves this way by default, for both interactive and script use, without needing an equivalent option enabled; POSIX sh and Dash offer no equivalent at all. This is exactly why “pipeline variables don’t survive” is a rule with real, shell-specific exceptions rather than a universal law — and why portable code still should not depend on it.

The most consequential exported variable: PATH

Of every variable a shell exports, PATH has the largest practical blast radius: it governs which executable actually runs when a bare command name is typed, and every child process inherits whatever PATH its parent shell had exported. Prepending an untrusted or writable directory to PATH — including ., the current directory, which some older shell configurations still add — creates a well-known privilege-escalation risk: a program named identically to a common command, placed earlier in PATH, silently intercepts every subsequent invocation of that name in any descendant shell. This is precisely why root’s PATH is conventionally kept minimal and free of user-writable directories, and why appending to PATH is a smaller, more contained action than exporting most other variables.

Reading the right layer without guessing

export -p prints every currently exported name as a reusable export command. declare -x in Bash, or typeset -x in Ksh and Zsh, marks a variable exported at the point of assignment. env -i command runs a command against a deliberately empty environment, useful for testing whether a script wrongly depends on a variable it never explicitly set. None of these substitute for one another: confusing “currently set in this shell” with “will exist in a child’s environment” is the same category of mistake the parent/subshell distinction causes elsewhere in this post.

A closing distinction worth keeping straight

“Set” (a shell variable exists in this shell), “exported” (marked to appear in future children’s environments), and “in the environment right now” (already inherited from this shell’s own parent) are three different facts, and it is easy to keep only one of them in your head at a time. A change to any of the first two never retroactively edits the third for a process already running — the same subshell-boundary principle from the top of this post, just restated for environment inheritance specifically.

Related:

Sources:

Comments