Variables and the Environment
A variable in the shell is a named string. You create one with NAME=value — no spaces around the = — and read it back with $NAME or ${NAME}. Until you export it, that variable is a shell variable: it lives only inside the current shell process and is invisible to anything that shell launches. The moment you run export NAME, it becomes an environment variable and is copied into the environment block of every child process the shell forks from then on.
That copy is one-way and one-time. A child reads its environment when it starts and never sees a later change in the parent; nothing a child does to its own environment ever flows back up. This is why a variable you set in a sub-shell vanishes when it exits, why cd has to be a builtin rather than a program, and why "the environment got changed but the running service didn't notice" is a daily reality on Linux servers. Almost every configuration question — where binaries are found, which locale formats dates, where a database driver looks for credentials — is ultimately a question about who exported what, into which process, and when.
Shell Variables versus Environment Variables
Set without export, a variable stays local to the shell. It is fine for loop counters, intermediate values, and anything the current script alone needs. Export it and you have made a promise to every program you start: this value is part of your runtime context. You can inspect the two sets separately — set (or declare -p) lists shell variables and functions, while env and printenv list only what was exported. A variable that shows up in set but not in env is one a child process will never see.
GREETING=hello # shell variable, this process only export EDITOR=vim # exported: every child inherits it declare -x PAGER=less # declare -x is the same as export bash -c 'echo "$GREETING/$EDITOR"' # prints /vim — GREETING was never exported export -n EDITOR # un-export: keep the value, stop inheriting it
The export flag is a property of the variable, not a separate copy. export -n NAME removes the flag while keeping the value; unset NAME removes the variable entirely. There is no namespace separating the two — exporting an existing shell variable simply marks it for inheritance.
Inheritance and the Process Model
When the shell runs an external command, it calls fork() to clone itself and then execve() to replace the clone with the new program. The third argument to execve() is the environment array, and that is the only channel through which exported variables travel. A program reads its environment once, at this moment, into the C library's environ pointer. Change a parent variable afterward and the already-running child is frozen with the old copy — the kernel offers no mechanism to push an update into a live process's environment.
This explains a class of confusion that catches people out. Editing /etc/environment or a ~/.profile does nothing to your current session's already-running programs; they keep the environment they were born with. A long-running daemon started by systemd holds whatever environment systemd gave it at launch, so adding a variable to your interactive shell never reaches it — you have to restart the unit. The rule is blunt: to change a process's environment, you restart the process.
# Inspect the live environment of any running process by PID tr '\0' '\n' < /proc/$(pgrep -f myapp)/environ # A one-shot variable for a single command, without exporting it for the session: LC_ALL=C sort data.txt # LC_ALL is set only for this one sort
PATH and Command Resolution
PATH is a colon-separated list of directories the shell searches, left to right, to resolve an unqualified command name. The first match wins, so order is policy: put /usr/local/bin ahead of /usr/bin and a locally installed tool shadows the distribution's copy. On Debian and Ubuntu the default for a login shell is built in /etc/profile and /etc/environment, typically /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin. After changing PATH, the shell caches command locations, so run hash -r to force it to re-scan rather than keep calling a binary that has since moved.
An empty element in PATH — a leading colon, a trailing colon, or :: in the middle — means "the current directory", which is the same security hole as putting . on the path. Drop a malicious ls into a shared directory and anyone whose PATH includes . runs it by typing ls. Leave . off entirely; call local programs with an explicit ./prog.
# Prepend a directory for THIS session, preserving the rest export PATH="$HOME/.local/bin:$PATH" type -a python3 # show every PATH match, in search order command -v curl # print the single binary that would actually run
Locale, TERM, and the Other Standard Variables
A handful of exported variables change program behavior without any program asking for them by name. LANG and the more specific LC_* set the locale, which controls collation order, decimal separators, date formatting, and character encoding; LC_ALL overrides them all. This is why a script that sorts fine on your laptop produces a different order on a server in another locale, and why setting LC_ALL=C gives you fast, stable, byte-order behavior for parsing. TERM tells curses programs what escape codes the terminal understands; a wrong TERM over SSH is why htop or vim renders garbage on a remote host.
HOME, USER, SHELL, and PWD are set for you at login and read by countless programs to find dotfiles, identify the account, and resolve ~. Overriding them deliberately is occasionally useful — HOME=/tmp/test myapp runs a program against a throwaway config directory — but overriding them by accident breaks tools that trusted them.
| Variable | Set by | What reads it |
|---|---|---|
PATH | profile, /etc/environment | The shell, to resolve command names |
HOME | login / PAM | ~ expansion, every tool that reads dotfiles |
LANG / LC_* | locale config, profile | sort, date, numeric formatting, encoding |
TERM | the terminal emulator | curses apps: vim, htop, less |
SHELL | /etc/passwd at login | programs that spawn "your" shell |
Scope: Per-Command, Per-Shell, and Persistent
There are three places a variable can live, and choosing the wrong one is the source of most "it works in my terminal but not in cron" tickets. Per-command scope — VAR=value cmd — sets a variable for exactly one process and is the cleanest way to pass one-off configuration without polluting the session. Per-shell scope — export VAR=value at the prompt — lasts until the shell exits. Persistent scope means writing the assignment into a startup file so every future shell gets it.
Where the persistent assignment goes depends on shell type. A login shell (SSH, console, bash --login) reads /etc/profile then the first of ~/.bash_profile, ~/.bash_login, or ~/.profile; a non-login interactive shell (a new terminal tab) reads ~/.bashrc; a script reads neither. The Debian and Ubuntu convention: put environment and PATH in ~/.profile, put aliases and prompt in ~/.bashrc (the stock ~/.profile sources ~/.bashrc so a login terminal gets both). System-wide values that must reach every login and the display manager go in /etc/environment — a plain KEY=value file parsed by PAM, not a shell script, so no $ expansion and no export keyword. For a systemd service, none of those apply: set its environment with Environment= or EnvironmentFile= in the unit.
Shell variable — set with NAME=value and never exported. Visible only inside the current shell and its builtins; a child process you launch cannot see it. Use it for loop counters, temporary values, and anything no other program needs. It shows up in set but not in env.
Environment variable — a shell variable that has been exported. Copied into the environment of every child the shell forks afterward, which is the only way a launched program receives configuration like PATH or LANG. It shows up in both set and env. Export only what a child genuinely needs — everything exported is inherited by every descendant.
- Putting spaces around
=—NAME = valueis parsed as running the commandNAMEwith arguments=andvalue, not an assignment. The shell reports "command not found" and your variable is never set. - Setting a variable without
exportand then wondering why a launched program can't see it.API_KEY=secret myappworks only because the inline form exports for that one command; a bareAPI_KEY=secreton its own line stays a shell variable. - Overwriting
PATHwithexport PATH=/my/dirinstead ofexport PATH=/my/dir:$PATH— the shell loses/usr/bin, and suddenlyls,grep, and evensudoare "not found" for the rest of the session. - Editing
~/.profileor/etc/environmentand expecting a runningsystemdservice to pick it up. Daemons keep the environment they were started with; onlysystemctl restart(or a new login) re-reads it. - Writing shell syntax into
/etc/environment— it is parsed by PAM as plainKEY=valuelines, soexport FOO=barorPATH=$PATH:/xeither fails to parse or stores the literal string$PATH. - Editing
PATHnon-idempotently in a startup file —PATH="$PATH:/opt/x"with no guard appends a duplicate on every login, soPATHgrows without bound across nested shells. - Using an unquoted variable that contains spaces or globs —
cp $FILE /destword-splits a path with a space into two arguments. Always quote:cp "$FILE" /dest.
- Export only what a child process genuinely needs; keep script-internal values as plain shell variables so they don't leak into every program you launch.
- Prepend to
PATHwithexport PATH="/new/dir:$PATH"and never assign it bare — preserve the system directories every time. - Use per-command scope (
VAR=value cmd) for one-offs instead of exporting into the whole session, so a temporary setting cannot leak into later commands. - Put environment and
PATHin~/.profileand interactive aliases and prompt in~/.bashrc; for system-wide values use/etc/environment(PAM) or a unit'sEnvironment=/EnvironmentFile=directive. - Set
LC_ALL=Cfor scripts that sort or parse text, so locale differences between machines never change collation order or number formatting under you. - Quote every variable expansion —
"$VAR","${ARR[@]}"— to stop word-splitting and glob expansion from mangling paths and arguments. - Run
hash -rafter moving a binary or changingPATHso the shell forgets its cached command locations and re-resolves from the new path.
set/setx and $env: in PowerShell; the registry holds persistent User and System variables instead of dotfilesmacOS — the same bash/zsh model, but GUI apps read launchctl setenv, not ~/.profilesystemd — Environment= and EnvironmentFile= set a unit's environment declaratively instead of through shell startup filesKnowledge Check
You set API_KEY=secret on its own line, then run myapp, and myapp reports the key is missing. Why?
API_KEYis a shell variable, not exported, so it is never placed in the child's environment — onlyexported variables are inherited- The value must be wrapped in quotes as
API_KEY="secret", or the shell silently discards the whole assignment before the program is ever launched in front of it myappreads its environment too late during its own startup sequence; you have to set the variable only after the program has already been launched and is running- Shell variables are passed down to child processes normally, but they get cleared the moment the variable name happens to be written entirely in uppercase letters
You add a variable to /etc/environment, but a systemd-managed daemon never sees it. What is the correct fix?
- Restart the unit — a running process keeps the environment it was launched with; set the value via the unit's
Environment=/EnvironmentFile=and reload - Run
source /etc/environmentin your shell so the new value immediately propagates to every running process on the box - Re-export the variable from your interactive login shell with
export, since child daemons inherit live updates from any active login session on the host - Move the assignment into
~/.bashrc, whichsystemdsources for the service account every time it starts the unit
What is wrong with export PATH=/opt/tool/bin as a way to add a directory?
- It replaces the entire
PATHinstead of extending it, so the system directories vanish and ordinary commands stop resolving for the rest of the session - It appends the new directory silently at the very end of the existing
PATH, so any system binary already on the path that shares the tool's name will always be found first and shadow it exportcannot legally be applied toPATHat an interactive prompt at all; the variable may only ever be assigned a value from inside the/etc/environmentfile- It silently appends the current working directory onto the end of
PATHas an empty trailing element, quietly opening up a real security hole on the host
A script sorts a file correctly on your laptop but produces a different order on a server. What is the most likely cause?
- The two machines have different
LANG/LC_*locales, which changesort's collation order; settingLC_ALL=Cmakes it deterministic sortconsults the order ofPATHto decide its collation order, and the server'sPATHsimply happens to list its component directories in a different sequence- The server exports a shell variable named
sortthat shadows the real binary and quietly overrides its ordering behavior TERMdiffers between the two machines, andsortquietly adjusts the order of its output lines according to the terminal type that gets reported to it
Where should a per-user PATH change live on Debian/Ubuntu so that it applies to SSH logins?
~/.profile, which a login shell reads; interactive-only items like aliases belong in~/.bashrc~/.bashrc, because login shells read it directly and~/.profileis ignored over SSH/etc/environment, written with theexport PATH=$PATH:/newshell syntax so the variable expands at login~/.bash_history, which the shell replays line by line on each new login session to restore the environment
You got correct