The Environment
6 min read
Core idea
The environment is a parent-to-child message bus
Every process in Linux carries an environment — a list of NAME=VALUE strings that the kernel hands to the child when a parent calls exec(). The shell uses this environment for its own configuration (PATH, HOME, USER, PS1) and exposes it to every command it launches. Programs read environment variables to discover their pager (PAGER), their editor (EDITOR), their locale (LANG), their terminal type (TERM), and a hundred other settings that would be tedious to specify on every invocation.
Two distinct categories live in the same namespace inside bash: shell variables (visible only to the current bash instance) and environment variables (copied to every child process). The export command is the bridge — it marks a shell variable as exportable, so that future children will receive it. A child cannot modify its parent's environment; when the child exits, any changes it made go with it.
Bash reads different dotfiles in different situations
When bash starts, it reads a precisely-defined sequence of startup files to configure itself. Which files it reads depends on whether the shell is a login shell (you authenticated — a graphical login, an SSH connection, a virtual console) or a non-login shell (you opened a new terminal tab in your already-logged-in desktop), and whether it is interactive (you typed at it) or non-interactive (a script).
The most consequential split is login vs. non-login. Login shells read /etc/profile and then the first of ~/.bash_profile, ~/.bash_login, or ~/.profile that exists. Non-login interactive shells read /etc/bash.bashrc and ~/.bashrc. The reason ~/.bashrc is the file everyone modifies is that most login dotfiles include a line that sources ~/.bashrc from inside them — so changes in ~/.bashrc apply to both kinds of shells.
Why it matters
Misunderstanding inheritance is the root cause of "it works in my shell but not in my script"
A variable you set without export is invisible to every program you run from that shell. A PATH change in ~/.bashrc is invisible to a cron job (cron uses a non-interactive non-login shell that does not read ~/.bashrc). A variable a child process exports is gone the moment the child exits. Hours of debugging dissolve once you internalize the one-way, one-shot nature of environment propagation: parent → child, at the moment of exec, and never back.
The PATH variable is your single point of customization
Every command you type without an absolute path is resolved by walking PATH directory by directory in order. Add ~/.local/bin to the front and your personal scripts shadow system commands. Add /usr/local/bin somewhere sensible and Homebrew-installed tools become available. Drop a directory off PATH and you accidentally lose access to half the tools on the system. PATH is small, important, and one of the few places where the order of items matters.
Key takeaways
Mental model
How a child process gets its environment
The kernel copies the parent's exported variables — and only those — into the child's environment at the moment of exec(). Shell variables that were never exported are not visible. Anything the child changes lives and dies with the child.
Which dotfile gets read when
Practical application
-
Audit your environment with
printenv | sort | less. The list is short — usually 20-40 entries. Read it once and you understand 80% of what programs on your system know about you. NoticePATH,HOME,LANG,EDITOR,TERM— these are the ones that most often affect program behavior. -
Put the right thing in the right dotfile. Rule of thumb: PATH and exported environment variables go in
~/.bash_profile. Aliases, shell functions, prompt setup, HISTSIZE, completion sourcing go in~/.bashrc. Make sure~/.bash_profileends with[[ -f ~/.bashrc ]] && . ~/.bashrcso interactive logins get both. -
Always quote when expanding into a path.
export PATH="$HOME/bin:$PATH"— notexport PATH=$HOME/bin:$PATH. If$HOMEis empty for any reason (rare but possible in early-boot or container environments), the unquoted form silently makesPATHstart with/bin:(with a leading colon, which means "current directory" — a classic security footgun). -
Use
export VAR=valuefor new exports, plainVAR=valuefor shell-only. A common pattern:export PATH="$HOME/.local/bin:$PATH"(must propagate to children);local_tmp=/tmp/work(only this script needs it). Don't export everything reflexively — exported variables clutter every child process's environment. -
Use one-off overrides for ephemeral changes.
LANG=C ls -lrunslsonce with localeC(uniform sorting, ASCII output) without changing your login locale.EDITOR=nano crontab -eoverrides your default editor for one invocation. The override applies to that command's environment only, nounsetcleanup needed. -
Reload changes immediately with
source. After editing~/.bashrc, runsource ~/.bashrc(or. ~/.bashrc) in your current shell — no need to close and reopen the terminal. Equally useful:source ./env.shto load a set of variables defined in a file into the current shell. -
Inspect a single variable without
echo $VARambiguity.printenv PATHshows exactly the value of PATH from the environment, no quoting, no expansion. For shell variables that aren't exported,declare -p VARshows the variable, its attributes, and its value as bash sees it internally.
Example
Tracing why a cron job can't find your script
You have a working script ~/bin/backup.sh that you can run from your shell. You schedule it with crontab -e:
0 3 * * * backup.sh > /var/log/backup.log 2>&1
The next morning the log is empty and the job clearly didn't run. What happened?
Cron starts a non-interactive non-login shell to run your job. That shell does not read ~/.bashrc or ~/.bash_profile. Whatever you added to PATH in those files is invisible. The default PATH for cron jobs is usually just /usr/bin:/bin — ~/bin is not on it, and bare backup.sh fails to resolve.
Three correct fixes, in order of preference:
# Option 1: absolute path (most portable)
0 3 * * * /home/me/bin/backup.sh > /var/log/backup.log 2>&1
# Option 2: set PATH at the top of the crontab
PATH=/usr/local/bin:/usr/bin:/bin:/home/me/bin
0 3 * * * backup.sh > /var/log/backup.log 2>&1
# Option 3: source your dotfile explicitly
0 3 * * * bash -lc 'backup.sh > /var/log/backup.log 2>&1'
The lesson generalizes: any environment cron jobs, systemd units, container entrypoints, and ssh user@host command all run is different from the one you see at your interactive prompt. The cure is to be explicit about paths and variables, not to assume the dotfiles you live with at the prompt are universally present.
Why export PATH=$PATH:$HOME/bin belongs in .bash_profile, not .bashrc
If you put export PATH="$PATH:$HOME/bin" inside ~/.bashrc, every new shell instance re-appends $HOME/bin to PATH. Open ten terminal tabs and your PATH grows to ten copies of the same directory:
$ echo $PATH
/usr/local/bin:/usr/bin:/bin:/home/me/bin:/home/me/bin:/home/me/bin:/home/me/bin: ...
This works (duplicates do no harm), but it inflates the environment and makes echo $PATH impossible to read. The fix is to put the PATH modification in ~/.bash_profile, which is read once at login. The variable is exported, so every shell descended from the login session inherits the modified PATH automatically — no need to re-append.
A defensive pattern that avoids duplicates even if the line runs twice:
# In ~/.bash_profile
case ":$PATH:" in
*":$HOME/bin:"*) ;; # already there, do nothing
*) export PATH="$HOME/bin:$PATH" ;;
esac
The case statement checks whether $HOME/bin is already in :$PATH: (the colons on both ends ensure boundary matches). It prepends only if absent. This idiom is worth memorizing — it appears in distribution-shipped profile scripts for the same reason.
Related lessons
Related concepts
- Environment Variableslinked concept
- Shelllinked concept