Variables and the Environment
Topic 16

Variables and the Environment

The ShellEnvironment

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.

VariableSet byWhat reads it
PATHprofile, /etc/environmentThe shell, to resolve command names
HOMElogin / PAM~ expansion, every tool that reads dotfiles
LANG / LC_*locale config, profilesort, date, numeric formatting, encoding
TERMthe terminal emulatorcurses apps: vim, htop, less
SHELL/etc/passwd at loginprograms 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 vs Environment Variable

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.

Common Mistakes
  • Putting spaces around =NAME = value is parsed as running the command NAME with arguments = and value, not an assignment. The shell reports "command not found" and your variable is never set.
  • Setting a variable without export and then wondering why a launched program can't see it. API_KEY=secret myapp works only because the inline form exports for that one command; a bare API_KEY=secret on its own line stays a shell variable.
  • Overwriting PATH with export PATH=/my/dir instead of export PATH=/my/dir:$PATH — the shell loses /usr/bin, and suddenly ls, grep, and even sudo are "not found" for the rest of the session.
  • Editing ~/.profile or /etc/environment and expecting a running systemd service to pick it up. Daemons keep the environment they were started with; only systemctl restart (or a new login) re-reads it.
  • Writing shell syntax into /etc/environment — it is parsed by PAM as plain KEY=value lines, so export FOO=bar or PATH=$PATH:/x either fails to parse or stores the literal string $PATH.
  • Editing PATH non-idempotently in a startup file — PATH="$PATH:/opt/x" with no guard appends a duplicate on every login, so PATH grows without bound across nested shells.
  • Using an unquoted variable that contains spaces or globs — cp $FILE /dest word-splits a path with a space into two arguments. Always quote: cp "$FILE" /dest.
Best Practices
  • 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 PATH with export 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 PATH in ~/.profile and interactive aliases and prompt in ~/.bashrc; for system-wide values use /etc/environment (PAM) or a unit's Environment=/EnvironmentFile= directive.
  • Set LC_ALL=C for 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 -r after moving a binary or changing PATH so the shell forgets its cached command locations and re-resolves from the new path.
Comparable toolsWindowsset/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 ~/.profilesystemdEnvironment= and EnvironmentFile= set a unit's environment declaratively instead of through shell startup files

Knowledge Check

You set API_KEY=secret on its own line, then run myapp, and myapp reports the key is missing. Why?

  • API_KEY is a shell variable, not exported, so it is never placed in the child's environment — only exported 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
  • myapp reads 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/environment in 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, which systemd sources 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 PATH instead 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
  • export cannot legally be applied to PATH at an interactive prompt at all; the variable may only ever be assigned a value from inside the /etc/environment file
  • It silently appends the current working directory onto the end of PATH as 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 change sort's collation order; setting LC_ALL=C makes it deterministic
  • sort consults the order of PATH to decide its collation order, and the server's PATH simply happens to list its component directories in a different sequence
  • The server exports a shell variable named sort that shadows the real binary and quietly overrides its ordering behavior
  • TERM differs between the two machines, and sort quietly 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 ~/.profile is ignored over SSH
  • /etc/environment, written with the export PATH=$PATH:/new shell 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